├── LICENSE ├── Makefile ├── README.md ├── api └── server.go ├── build └── env.sh ├── config.example.json ├── docs ├── PAYOUTS.md ├── POLICIES.md └── STRATUM.md ├── main.go ├── misc ├── nginx-default.conf └── upstart.conf ├── payouts ├── payer.go ├── unlocker.go └── unlocker_test.go ├── policy └── policy.go ├── proxy ├── blocks.go ├── config.go ├── handlers.go ├── miner.go ├── proto.go ├── proxy.go └── stratum.go ├── rpc └── rpc.go ├── storage ├── redis.go └── redis_test.go ├── util └── util.go └── www ├── README.md ├── app ├── app.js ├── components │ └── active-li.js ├── controllers │ ├── account.js │ ├── application.js │ ├── help.js │ └── index.js ├── formats.js ├── helpers │ ├── format-balance.js │ ├── format-date-locale.js │ ├── format-hashrate.js │ ├── format-tx.js │ ├── seconds-to-ms.js │ ├── string-to-int.js │ └── with-metric-prefix.js ├── index.html ├── models │ ├── block.js │ └── payment.js ├── resolver.js ├── router.js ├── routes │ ├── account.js │ ├── application.js │ ├── blocks.js │ ├── index.js │ ├── miners.js │ └── payments.js ├── styles │ └── app.css └── templates │ ├── about.hbs │ ├── account.hbs │ ├── account │ ├── index.hbs │ └── payouts.hbs │ ├── application-error.hbs │ ├── application.hbs │ ├── blocks.hbs │ ├── blocks │ ├── block.hbs │ ├── immature.hbs │ ├── index.hbs │ └── pending.hbs │ ├── components │ └── active-li.hbs │ ├── help.hbs │ ├── index.hbs │ ├── luck.hbs │ ├── miners.hbs │ ├── not-found.hbs │ └── payments.hbs ├── bower.json ├── build.sh ├── config ├── ember-intl.js └── environment.js ├── ember-cli-build.js ├── package.json ├── public ├── bg.png ├── crossdomain.xml ├── favicon.ico └── robots.txt ├── testem.json ├── tests ├── helpers │ ├── destroy-app.js │ ├── module-for-acceptance.js │ ├── resolver.js │ └── start-app.js ├── index.html └── test-helper.js └── translations └── en-us.yaml /Makefile: -------------------------------------------------------------------------------- 1 | # This Makefile is meant to be used by people that do not usually work 2 | # with Go source code. If you know what GOPATH is then you probably 3 | # don't need to bother with make. 4 | 5 | .PHONY: all test clean 6 | 7 | GOBIN = build/bin 8 | 9 | all: 10 | build/env.sh go get -v ./... 11 | 12 | test: all 13 | build/env.sh go test -v ./... 14 | 15 | clean: 16 | rm -fr build/_workspace/pkg/ $(GOBIN)/* 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # open-ethereum-pool 2 | 进一步开发中,为以太坊矿工提供一个易于使用的矿池。 3 | 该软件功能正常,但预计不久将优化版本库。欢迎测试和错误提交! 4 | # 5 | 支持HTTP和Stratum挖掘 6 | 具有运气比例和全部奖励的详细区块统计数据 7 | 故障转移geth实例:geth内置高可用性 8 | 建立在Linux上 9 | # 10 | go >= 1.9 11 | geth or parity 12 | redis-server >= 2.8.0 13 | nodejs >= 4 LTS 14 | nginx 15 | 我强烈建议使用Ubuntu 16.04 LTS。 16 | # 17 | 首先安装 go-ethereum。 18 | # 19 | 克隆和编译: 20 | 21 | git clone https://github.com/OpenEthMine/open-ethereum-pool 22 | cd open-ethereum-pool 23 | make 24 | 安装redis-server。 25 | 26 | 运行池 27 | ./build/bin/open-ethereum-pool config.json 28 | 29 | 30 | npm install -g ember-cli@2.9.1 31 | npm install -g bower 32 | npm install 33 | bower install 34 | ./build.sh 35 | 36 | upstream api { 37 | server 127.0.0.1:8080; 38 | } 39 | 40 | location /api { 41 | proxy_pass http://api; 42 | } 43 | 44 | 45 | { 46 | // Set to the number of CPU cores of your server 47 | "threads": 2, 48 | // Prefix for keys in redis store 49 | "coin": "eth", 50 | // Give unique name to each instance 51 | "name": "main", 52 | 53 | "proxy": { 54 | "enabled": true, 55 | 56 | // Bind HTTP mining endpoint to this IP:PORT 57 | "listen": "0.0.0.0:8888", 58 | 59 | // Allow only this header and body size of HTTP request from miners 60 | "limitHeadersSize": 1024, 61 | "limitBodySize": 256, 62 | 63 | /* Set to true if you are behind CloudFlare (not recommended) or behind http-reverse 64 | proxy to enable IP detection from X-Forwarded-For header. 65 | Advanced users only. It's tricky to make it right and secure. 66 | */ 67 | "behindReverseProxy": false, 68 | 69 | // Stratum mining endpoint 70 | "stratum": { 71 | "enabled": true, 72 | // Bind stratum mining socket to this IP:PORT 73 | "listen": "0.0.0.0:8008", 74 | "timeout": "120s", 75 | "maxConn": 8192 76 | }, 77 | 78 | // Try to get new job from geth in this interval 79 | "blockRefreshInterval": "120ms", 80 | "stateUpdateInterval": "3s", 81 | // Require this share difficulty from miners 82 | "difficulty": 2000000000, 83 | 84 | /* Reply error to miner instead of job if redis is unavailable. 85 | Should save electricity to miners if pool is sick and they didn't set up failovers. 86 | */ 87 | "healthCheck": true, 88 | // Mark pool sick after this number of redis failures. 89 | "maxFails": 100, 90 | // TTL for workers stats, usually should be equal to large hashrate window from API section 91 | "hashrateExpiration": "3h", 92 | 93 | "policy": { 94 | "workers": 8, 95 | "resetInterval": "60m", 96 | "refreshInterval": "1m", 97 | 98 | "banning": { 99 | "enabled": false, 100 | /* Name of ipset for banning. 101 | Check http://ipset.netfilter.org/ documentation. 102 | */ 103 | "ipset": "blacklist", 104 | // Remove ban after this amount of time 105 | "timeout": 1800, 106 | // Percent of invalid shares from all shares to ban miner 107 | "invalidPercent": 30, 108 | // Check after after miner submitted this number of shares 109 | "checkThreshold": 30, 110 | // Bad miner after this number of malformed requests 111 | "malformedLimit": 5 112 | }, 113 | // Connection rate limit 114 | "limits": { 115 | "enabled": false, 116 | // Number of initial connections 117 | "limit": 30, 118 | "grace": "5m", 119 | // Increase allowed number of connections on each valid share 120 | "limitJump": 10 121 | } 122 | } 123 | }, 124 | 125 | // Provides JSON data for frontend which is static website 126 | "api": { 127 | "enabled": true, 128 | "listen": "0.0.0.0:8080", 129 | // Collect miners stats (hashrate, ...) in this interval 130 | "statsCollectInterval": "5s", 131 | // Purge stale stats interval 132 | "purgeInterval": "10m", 133 | // Fast hashrate estimation window for each miner from it's shares 134 | "hashrateWindow": "30m", 135 | // Long and precise hashrate from shares, 3h is cool, keep it 136 | "hashrateLargeWindow": "3h", 137 | // Collect stats for shares/diff ratio for this number of blocks 138 | "luckWindow": [64, 128, 256], 139 | // Max number of payments to display in frontend 140 | "payments": 50, 141 | // Max numbers of blocks to display in frontend 142 | "blocks": 50, 143 | 144 | /* If you are running API node on a different server where this module 145 | is reading data from redis writeable slave, you must run an api instance with this option enabled in order to purge hashrate stats from main redis node. 146 | Only redis writeable slave will work properly if you are distributing using redis slaves. 147 | Very advanced. Usually all modules should share same redis instance. 148 | */ 149 | "purgeOnly": false 150 | }, 151 | 152 | // Check health of each geth node in this interval 153 | "upstreamCheckInterval": "5s", 154 | 155 | /* List of geth nodes to poll for new jobs. Pool will try to get work from 156 | first alive one and check in background for failed to back up. 157 | Current block template of the pool is always cached in RAM indeed. 158 | */ 159 | "upstream": [ 160 | { 161 | "name": "main", 162 | "url": "http://127.0.0.1:8545", 163 | "timeout": "10s" 164 | }, 165 | { 166 | "name": "backup", 167 | "url": "http://127.0.0.2:8545", 168 | "timeout": "10s" 169 | } 170 | ], 171 | 172 | // This is standard redis connection options 173 | "redis": { 174 | // Where your redis instance is listening for commands 175 | "endpoint": "127.0.0.1:6379", 176 | "poolSize": 10, 177 | "database": 0, 178 | "password": "" 179 | }, 180 | 181 | // This module periodically remits ether to miners 182 | "unlocker": { 183 | "enabled": false, 184 | // Pool fee percentage 185 | "poolFee": 1.0, 186 | // Pool fees beneficiary address (leave it blank to disable fee withdrawals) 187 | "poolFeeAddress": "", 188 | // Donate 10% from pool fees to developers 189 | "donate": true, 190 | // Unlock only if this number of blocks mined back 191 | "depth": 120, 192 | // Simply don't touch this option 193 | "immatureDepth": 20, 194 | // Keep mined transaction fees as pool fees 195 | "keepTxFees": false, 196 | // Run unlocker in this interval 197 | "interval": "10m", 198 | // Geth instance node rpc endpoint for unlocking blocks 199 | "daemon": "http://127.0.0.1:8545", 200 | // Rise error if can't reach geth in this amount of time 201 | "timeout": "10s" 202 | }, 203 | 204 | // Pay out miners using this module 205 | "payouts": { 206 | "enabled": false, 207 | // Require minimum number of peers on node 208 | "requirePeers": 25, 209 | // Run payouts in this interval 210 | "interval": "12h", 211 | // Geth instance node rpc endpoint for payouts processing 212 | "daemon": "http://127.0.0.1:8545", 213 | // Rise error if can't reach geth in this amount of time 214 | "timeout": "10s", 215 | // Address with pool balance 216 | "address": "0x0", 217 | // Let geth to determine gas and gasPrice 218 | "autoGas": true, 219 | // Gas amount and price for payout tx (advanced users only) 220 | "gas": "21000", 221 | "gasPrice": "50000000000", 222 | // Send payment only if miner's balance is >= 0.5 Ether 223 | "threshold": 500000000, 224 | // Perform BGSAVE on Redis after successful payouts session 225 | "bgsave": false 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | "sort" 8 | "strings" 9 | "sync" 10 | "sync/atomic" 11 | "time" 12 | 13 | "github.com/gorilla/mux" 14 | 15 | "github.com/sammy007/open-ethereum-pool/storage" 16 | "github.com/sammy007/open-ethereum-pool/util" 17 | ) 18 | 19 | type ApiConfig struct { 20 | Enabled bool `json:"enabled"` 21 | Listen string `json:"listen"` 22 | StatsCollectInterval string `json:"statsCollectInterval"` 23 | HashrateWindow string `json:"hashrateWindow"` 24 | HashrateLargeWindow string `json:"hashrateLargeWindow"` 25 | LuckWindow []int `json:"luckWindow"` 26 | Payments int64 `json:"payments"` 27 | Blocks int64 `json:"blocks"` 28 | PurgeOnly bool `json:"purgeOnly"` 29 | PurgeInterval string `json:"purgeInterval"` 30 | } 31 | 32 | type ApiServer struct { 33 | config *ApiConfig 34 | backend *storage.RedisClient 35 | hashrateWindow time.Duration 36 | hashrateLargeWindow time.Duration 37 | stats atomic.Value 38 | miners map[string]*Entry 39 | minersMu sync.RWMutex 40 | statsIntv time.Duration 41 | } 42 | 43 | type Entry struct { 44 | stats map[string]interface{} 45 | updatedAt int64 46 | } 47 | 48 | func NewApiServer(cfg *ApiConfig, backend *storage.RedisClient) *ApiServer { 49 | hashrateWindow := util.MustParseDuration(cfg.HashrateWindow) 50 | hashrateLargeWindow := util.MustParseDuration(cfg.HashrateLargeWindow) 51 | return &ApiServer{ 52 | config: cfg, 53 | backend: backend, 54 | hashrateWindow: hashrateWindow, 55 | hashrateLargeWindow: hashrateLargeWindow, 56 | miners: make(map[string]*Entry), 57 | } 58 | } 59 | 60 | func (s *ApiServer) Start() { 61 | if s.config.PurgeOnly { 62 | log.Printf("Starting API in purge-only mode") 63 | } else { 64 | log.Printf("Starting API on %v", s.config.Listen) 65 | } 66 | 67 | s.statsIntv = util.MustParseDuration(s.config.StatsCollectInterval) 68 | statsTimer := time.NewTimer(s.statsIntv) 69 | log.Printf("Set stats collect interval to %v", s.statsIntv) 70 | 71 | purgeIntv := util.MustParseDuration(s.config.PurgeInterval) 72 | purgeTimer := time.NewTimer(purgeIntv) 73 | log.Printf("Set purge interval to %v", purgeIntv) 74 | 75 | sort.Ints(s.config.LuckWindow) 76 | 77 | if s.config.PurgeOnly { 78 | s.purgeStale() 79 | } else { 80 | s.purgeStale() 81 | s.collectStats() 82 | } 83 | 84 | go func() { 85 | for { 86 | select { 87 | case <-statsTimer.C: 88 | if !s.config.PurgeOnly { 89 | s.collectStats() 90 | } 91 | statsTimer.Reset(s.statsIntv) 92 | case <-purgeTimer.C: 93 | s.purgeStale() 94 | purgeTimer.Reset(purgeIntv) 95 | } 96 | } 97 | }() 98 | 99 | if !s.config.PurgeOnly { 100 | s.listen() 101 | } 102 | } 103 | 104 | func (s *ApiServer) listen() { 105 | r := mux.NewRouter() 106 | r.HandleFunc("/api/stats", s.StatsIndex) 107 | r.HandleFunc("/api/miners", s.MinersIndex) 108 | r.HandleFunc("/api/blocks", s.BlocksIndex) 109 | r.HandleFunc("/api/payments", s.PaymentsIndex) 110 | r.HandleFunc("/api/accounts/{login:0x[0-9a-fA-F]{40}}", s.AccountIndex) 111 | r.NotFoundHandler = http.HandlerFunc(notFound) 112 | err := http.ListenAndServe(s.config.Listen, r) 113 | if err != nil { 114 | log.Fatalf("Failed to start API: %v", err) 115 | } 116 | } 117 | 118 | func notFound(w http.ResponseWriter, r *http.Request) { 119 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 120 | w.Header().Set("Access-Control-Allow-Origin", "*") 121 | w.Header().Set("Cache-Control", "no-cache") 122 | w.WriteHeader(http.StatusNotFound) 123 | } 124 | 125 | func (s *ApiServer) purgeStale() { 126 | start := time.Now() 127 | total, err := s.backend.FlushStaleStats(s.hashrateWindow, s.hashrateLargeWindow) 128 | if err != nil { 129 | log.Println("Failed to purge stale data from backend:", err) 130 | } else { 131 | log.Printf("Purged stale stats from backend, %v shares affected, elapsed time %v", total, time.Since(start)) 132 | } 133 | } 134 | 135 | func (s *ApiServer) collectStats() { 136 | start := time.Now() 137 | stats, err := s.backend.CollectStats(s.hashrateWindow, s.config.Blocks, s.config.Payments) 138 | if err != nil { 139 | log.Printf("Failed to fetch stats from backend: %v", err) 140 | return 141 | } 142 | if len(s.config.LuckWindow) > 0 { 143 | stats["luck"], err = s.backend.CollectLuckStats(s.config.LuckWindow) 144 | if err != nil { 145 | log.Printf("Failed to fetch luck stats from backend: %v", err) 146 | return 147 | } 148 | } 149 | s.stats.Store(stats) 150 | log.Printf("Stats collection finished %s", time.Since(start)) 151 | } 152 | 153 | func (s *ApiServer) StatsIndex(w http.ResponseWriter, r *http.Request) { 154 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 155 | w.Header().Set("Access-Control-Allow-Origin", "*") 156 | w.Header().Set("Cache-Control", "no-cache") 157 | w.WriteHeader(http.StatusOK) 158 | 159 | reply := make(map[string]interface{}) 160 | nodes, err := s.backend.GetNodeStates() 161 | if err != nil { 162 | log.Printf("Failed to get nodes stats from backend: %v", err) 163 | } 164 | reply["nodes"] = nodes 165 | 166 | stats := s.getStats() 167 | if stats != nil { 168 | reply["now"] = util.MakeTimestamp() 169 | reply["stats"] = stats["stats"] 170 | reply["hashrate"] = stats["hashrate"] 171 | reply["minersTotal"] = stats["minersTotal"] 172 | reply["maturedTotal"] = stats["maturedTotal"] 173 | reply["immatureTotal"] = stats["immatureTotal"] 174 | reply["candidatesTotal"] = stats["candidatesTotal"] 175 | } 176 | 177 | err = json.NewEncoder(w).Encode(reply) 178 | if err != nil { 179 | log.Println("Error serializing API response: ", err) 180 | } 181 | } 182 | 183 | func (s *ApiServer) MinersIndex(w http.ResponseWriter, r *http.Request) { 184 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 185 | w.Header().Set("Access-Control-Allow-Origin", "*") 186 | w.Header().Set("Cache-Control", "no-cache") 187 | w.WriteHeader(http.StatusOK) 188 | 189 | reply := make(map[string]interface{}) 190 | stats := s.getStats() 191 | if stats != nil { 192 | reply["now"] = util.MakeTimestamp() 193 | reply["miners"] = stats["miners"] 194 | reply["hashrate"] = stats["hashrate"] 195 | reply["minersTotal"] = stats["minersTotal"] 196 | } 197 | 198 | err := json.NewEncoder(w).Encode(reply) 199 | if err != nil { 200 | log.Println("Error serializing API response: ", err) 201 | } 202 | } 203 | 204 | func (s *ApiServer) BlocksIndex(w http.ResponseWriter, r *http.Request) { 205 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 206 | w.Header().Set("Access-Control-Allow-Origin", "*") 207 | w.Header().Set("Cache-Control", "no-cache") 208 | w.WriteHeader(http.StatusOK) 209 | 210 | reply := make(map[string]interface{}) 211 | stats := s.getStats() 212 | if stats != nil { 213 | reply["matured"] = stats["matured"] 214 | reply["maturedTotal"] = stats["maturedTotal"] 215 | reply["immature"] = stats["immature"] 216 | reply["immatureTotal"] = stats["immatureTotal"] 217 | reply["candidates"] = stats["candidates"] 218 | reply["candidatesTotal"] = stats["candidatesTotal"] 219 | reply["luck"] = stats["luck"] 220 | } 221 | 222 | err := json.NewEncoder(w).Encode(reply) 223 | if err != nil { 224 | log.Println("Error serializing API response: ", err) 225 | } 226 | } 227 | 228 | func (s *ApiServer) PaymentsIndex(w http.ResponseWriter, r *http.Request) { 229 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 230 | w.Header().Set("Access-Control-Allow-Origin", "*") 231 | w.Header().Set("Cache-Control", "no-cache") 232 | w.WriteHeader(http.StatusOK) 233 | 234 | reply := make(map[string]interface{}) 235 | stats := s.getStats() 236 | if stats != nil { 237 | reply["payments"] = stats["payments"] 238 | reply["paymentsTotal"] = stats["paymentsTotal"] 239 | } 240 | 241 | err := json.NewEncoder(w).Encode(reply) 242 | if err != nil { 243 | log.Println("Error serializing API response: ", err) 244 | } 245 | } 246 | 247 | func (s *ApiServer) AccountIndex(w http.ResponseWriter, r *http.Request) { 248 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 249 | w.Header().Set("Access-Control-Allow-Origin", "*") 250 | w.Header().Set("Cache-Control", "no-cache") 251 | 252 | login := strings.ToLower(mux.Vars(r)["login"]) 253 | s.minersMu.Lock() 254 | defer s.minersMu.Unlock() 255 | 256 | reply, ok := s.miners[login] 257 | now := util.MakeTimestamp() 258 | cacheIntv := int64(s.statsIntv / time.Millisecond) 259 | // Refresh stats if stale 260 | if !ok || reply.updatedAt < now-cacheIntv { 261 | exist, err := s.backend.IsMinerExists(login) 262 | if !exist { 263 | w.WriteHeader(http.StatusNotFound) 264 | return 265 | } 266 | if err != nil { 267 | w.WriteHeader(http.StatusInternalServerError) 268 | log.Printf("Failed to fetch stats from backend: %v", err) 269 | return 270 | } 271 | 272 | stats, err := s.backend.GetMinerStats(login, s.config.Payments) 273 | if err != nil { 274 | w.WriteHeader(http.StatusInternalServerError) 275 | log.Printf("Failed to fetch stats from backend: %v", err) 276 | return 277 | } 278 | workers, err := s.backend.CollectWorkersStats(s.hashrateWindow, s.hashrateLargeWindow, login) 279 | if err != nil { 280 | w.WriteHeader(http.StatusInternalServerError) 281 | log.Printf("Failed to fetch stats from backend: %v", err) 282 | return 283 | } 284 | for key, value := range workers { 285 | stats[key] = value 286 | } 287 | stats["pageSize"] = s.config.Payments 288 | reply = &Entry{stats: stats, updatedAt: now} 289 | s.miners[login] = reply 290 | } 291 | 292 | w.WriteHeader(http.StatusOK) 293 | err := json.NewEncoder(w).Encode(reply.stats) 294 | if err != nil { 295 | log.Println("Error serializing API response: ", err) 296 | } 297 | } 298 | 299 | func (s *ApiServer) getStats() map[string]interface{} { 300 | stats := s.stats.Load() 301 | if stats != nil { 302 | return stats.(map[string]interface{}) 303 | } 304 | return nil 305 | } 306 | -------------------------------------------------------------------------------- /build/env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if [ ! -f "build/env.sh" ]; then 6 | echo "$0 must be run from the root of the repository." 7 | exit 2 8 | fi 9 | 10 | # Create fake Go workspace if it doesn't exist yet. 11 | workspace="$PWD/build/_workspace" 12 | root="$PWD" 13 | ethdir="$workspace/src/github.com/sammy007" 14 | if [ ! -L "$ethdir/open-ethereum-pool" ]; then 15 | mkdir -p "$ethdir" 16 | cd "$ethdir" 17 | ln -s ../../../../../. open-ethereum-pool 18 | cd "$root" 19 | fi 20 | 21 | # Set up the environment to use the workspace. 22 | # Also add Godeps workspace so we build using canned dependencies. 23 | GOPATH="$workspace" 24 | GOBIN="$PWD/build/bin" 25 | export GOPATH GOBIN 26 | 27 | # Run the command inside the workspace. 28 | cd "$ethdir/open-ethereum-pool" 29 | PWD="$ethdir/open-ethereum-pool" 30 | 31 | # Launch the arguments with the configured environment. 32 | exec "$@" 33 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "threads": 2, 3 | "coin": "eth", 4 | "name": "main", 5 | 6 | "proxy": { 7 | "enabled": true, 8 | "listen": "0.0.0.0:8888", 9 | "limitHeadersSize": 1024, 10 | "limitBodySize": 256, 11 | "behindReverseProxy": false, 12 | "blockRefreshInterval": "120ms", 13 | "stateUpdateInterval": "3s", 14 | "difficulty": 2000000000, 15 | "hashrateExpiration": "3h", 16 | 17 | "healthCheck": true, 18 | "maxFails": 100, 19 | 20 | "stratum": { 21 | "enabled": true, 22 | "listen": "0.0.0.0:8008", 23 | "timeout": "120s", 24 | "maxConn": 8192 25 | }, 26 | 27 | "policy": { 28 | "workers": 8, 29 | "resetInterval": "60m", 30 | "refreshInterval": "1m", 31 | 32 | "banning": { 33 | "enabled": false, 34 | "ipset": "blacklist", 35 | "timeout": 1800, 36 | "invalidPercent": 30, 37 | "checkThreshold": 30, 38 | "malformedLimit": 5 39 | }, 40 | "limits": { 41 | "enabled": false, 42 | "limit": 30, 43 | "grace": "5m", 44 | "limitJump": 10 45 | } 46 | } 47 | }, 48 | 49 | "api": { 50 | "enabled": true, 51 | "purgeOnly": false, 52 | "purgeInterval": "10m", 53 | "listen": "0.0.0.0:8080", 54 | "statsCollectInterval": "5s", 55 | "hashrateWindow": "30m", 56 | "hashrateLargeWindow": "3h", 57 | "luckWindow": [64, 128, 256], 58 | "payments": 30, 59 | "blocks": 50 60 | }, 61 | 62 | "upstreamCheckInterval": "5s", 63 | "upstream": [ 64 | { 65 | "name": "main", 66 | "url": "http://127.0.0.1:8545", 67 | "timeout": "10s" 68 | }, 69 | { 70 | "name": "backup", 71 | "url": "http://127.0.0.2:8545", 72 | "timeout": "10s" 73 | } 74 | ], 75 | 76 | "redis": { 77 | "endpoint": "127.0.0.1:6379", 78 | "poolSize": 10, 79 | "database": 0, 80 | "password": "" 81 | }, 82 | 83 | "unlocker": { 84 | "enabled": false, 85 | "poolFee": 1.0, 86 | "poolFeeAddress": "", 87 | "donate": true, 88 | "depth": 120, 89 | "immatureDepth": 20, 90 | "keepTxFees": false, 91 | "interval": "10m", 92 | "daemon": "http://127.0.0.1:8545", 93 | "timeout": "10s" 94 | }, 95 | 96 | "payouts": { 97 | "enabled": false, 98 | "requirePeers": 25, 99 | "interval": "120m", 100 | "daemon": "http://127.0.0.1:8545", 101 | "timeout": "10s", 102 | "address": "0x0", 103 | "gas": "21000", 104 | "gasPrice": "50000000000", 105 | "autoGas": true, 106 | "threshold": 500000000, 107 | "bgsave": false 108 | }, 109 | 110 | "newrelicEnabled": false, 111 | "newrelicName": "MyEtherProxy", 112 | "newrelicKey": "SECRET_KEY", 113 | "newrelicVerbose": false 114 | } 115 | -------------------------------------------------------------------------------- /docs/PAYOUTS.md: -------------------------------------------------------------------------------- 1 | **First of all make sure your Redis instance and backups are configured properly http://redis.io/topics/persistence.** 2 | 3 | Keep in mind that pool maintains all balances in **Shannon**. 4 | 5 | # Processing and Resolving Payouts 6 | 7 | **You MUST run payouts module in a separate process**, ideally don't run it as daemon and process payouts 2-3 times per day and watch how it goes. **You must configure logging**, otherwise it can lead to big problems. 8 | 9 | Module will fetch accounts and sequentially process payouts. 10 | 11 | For every account who reached minimal threshold: 12 | 13 | * Check if we have enough peers on a node 14 | * Check that account is unlocked 15 | 16 | If any of checks fails, module will not even try to continue. 17 | 18 | * Check if we have enough money for payout (should not happen under normal circumstances) 19 | * Lock payments 20 | 21 | If payments can't be locked (another lock exist, usually after a failure) module will halt payouts. 22 | 23 | * Deduct balance of a miner and log pending payment 24 | * Submit a transaction to a node via `eth_sendTransaction` 25 | 26 | **If transaction submission fails, payouts will remain locked and halted in erroneous state.** 27 | 28 | If transaction submission was successful, we have a TX hash: 29 | 30 | * Write this TX hash to a database 31 | * Unlock payouts 32 | 33 | And so on. Repeat for every account. 34 | 35 | After payout session, payment module will perform `BGSAVE` (background saving) on Redis if you have enabled `bgsave` option. 36 | 37 | ## Resolving Failed Payments (automatic) 38 | 39 | If your payout is not logged and not confirmed by Ethereum network you can resolve it automatically. You need to payouts in maintenance mode by setting up `RESOLVE_PAYOUT=1` or `RESOLVE_PAYOUT=True` environment variable: 40 | 41 | `RESOLVE_PAYOUT=1 ./build/bin/open-ethereum-pool payouts.json`. 42 | 43 | Payout module will fetch all rows from Redis with key `eth:payments:pending` and credit balance back to miners. Usually you will have only single entry there. 44 | 45 | If you see `No pending payments to resolve` we have no data about failed debits. 46 | 47 | If there was a debit operation performed which is not followed by actual money transfer (after `eth_sendTransaction` returned an error), you will likely see: 48 | 49 | ``` 50 | Will credit back following balances: 51 | Address: 0xb85150eb365e7df0941f0cf08235f987ba91506a, Amount: 166798415 Shannon, 2016-05-11 08:14:34 52 | ``` 53 | 54 | followed by 55 | 56 | ``` 57 | Credited 166798415 Shannon back to 0xb85150eb365e7df0941f0cf08235f987ba91506a 58 | ``` 59 | 60 | Usually every maintenance run ends with following message and halt: 61 | 62 | ``` 63 | Payouts unlocked 64 | Now you have to restart payouts module with RESOLVE_PAYOUT=0 for normal run 65 | ``` 66 | 67 | Unset `RESOLVE_PAYOUT=1` or run payouts with `RESOLVE_PAYOUT=0`. 68 | 69 | ## Resolving Failed Payment (manual) 70 | 71 | You can perform manual maintenance using `geth` and `redis-cli` utilities. 72 | 73 | ### Check For Failed Transactions: 74 | 75 | Perform the following command in a `redis-cli`: 76 | 77 | ``` 78 | ZREVRANGE "eth:payments:pending" 0 -1 WITHSCORES 79 | ``` 80 | 81 | Result will be like this: 82 | 83 | > 1) "0xb85150eb365e7df0941f0cf08235f987ba91506a:25000000" 84 | 85 | It's a pair of `LOGIN:AMOUNT`. 86 | 87 | >2) "1462920526" 88 | 89 | It's a `UNIXTIME` 90 | 91 | ### Manual Payment Submission 92 | 93 | **Make sure there is no TX sent using block explorer. Skip this step if payment actually exist in a blockchain.** 94 | 95 | ```javascript 96 | eth.sendTransaction({ 97 | from: eth.coinbase, 98 | to: '0xb85150eb365e7df0941f0cf08235f987ba91506a', 99 | value: web3.toWei(25000000, 'shannon') 100 | }) 101 | 102 | // => 0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331 103 | ``` 104 | 105 | **Write down tx hash**. 106 | 107 | ### Store Payment in Redis 108 | 109 | Also usable for fixing missing payment entries. 110 | 111 | ``` 112 | ZADD "eth:payments:all" 1462920526 0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331:0xb85150eb365e7df0941f0cf08235f987ba91506a:25000000 113 | ``` 114 | 115 | ``` 116 | ZADD "eth:payments:0xb85150eb365e7df0941f0cf08235f987ba91506a" 1462920526 0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331:25000000 117 | ``` 118 | 119 | ### Delete Erroneous Payment Entry 120 | 121 | ``` 122 | ZREM "eth:payments:pending" "0xb85150eb365e7df0941f0cf08235f987ba91506a:25000000" 123 | ``` 124 | 125 | ### Update Internal Stats 126 | 127 | ``` 128 | HINCRBY "eth:finances" pending -25000000 129 | HINCRBY "eth:finances" paid 25000000 130 | ``` 131 | 132 | ### Unlock Payouts 133 | 134 | ``` 135 | DEL "eth:payments:lock" 136 | ``` 137 | 138 | ## Resolving Missing Payment Entries 139 | 140 | If pool actually paid but didn't log transaction, scroll up to `Store Payment in Redis` section. You should have a transaction hash from block explorer. 141 | 142 | ## Transaction Didn't Confirm 143 | 144 | If you are sure, just repeat it manually, you should have all the logs. 145 | -------------------------------------------------------------------------------- /docs/POLICIES.md: -------------------------------------------------------------------------------- 1 | # Enforcing Policies 2 | 3 | Pool policy server collecting several stats on per IP basis. There are two options: `iptables+ipset` or simple application level bans. Banning is disabled by default. 4 | 5 | ## Firewall Banning 6 | 7 | First you need to configure your firewall to use `ipset`, read [this article](https://wiki.archlinux.org/index.php/Ipset). 8 | 9 | Specify `ipset` name for banning in `policy` section. Timeout argument (in seconds) will be passed to this `ipset`. Stratum will use `os/exec` command like `sudo ipset add banlist x.x.x.x 1800` for banning, so you have to configure `sudo` properly and make sure that your system will never ask for password: 10 | 11 | Example `/etc/sudoers.d/pool` where `pool` is a username under which pool runs: 12 | 13 | pool ALL=NOPASSWD: /sbin/ipset 14 | 15 | If you need something simple, just set `ipset` name to blank string and simple application level banning will be used instead. 16 | 17 | ## Limiting 18 | 19 | Under some weird circumstances you can enforce limits to prevent connection flood to stratum, there are initial settings: `limit` and `limitJump`. Policy server will increase number of allowed connections per IP address on each valid share submission. Stratum will not enforce this policy for a `grace` period specified after stratum start. 20 | -------------------------------------------------------------------------------- /docs/STRATUM.md: -------------------------------------------------------------------------------- 1 | # Stratum Mining Protocol 2 | 3 | This is the description of stratum protocol used in this pool. 4 | 5 | Stratum defines simple exception handling. Example of rejected share looks like: 6 | 7 | ```javascript 8 | { "id": 1, "jsonrpc": "2.0", "result": null, "error": { code: 23, message: "Invalid share" } } 9 | ``` 10 | 11 | Each response with exception is followed by disconnect. 12 | 13 | ## Authentication 14 | 15 | Request looks like: 16 | 17 | ```javascript 18 | { 19 | "id": 1, 20 | "jsonrpc": "2.0", 21 | "method": "eth_submitLogin", 22 | "params": ["0xb85150eb365e7df0941f0cf08235f987ba91506a"] 23 | } 24 | ``` 25 | 26 | Request can include additional 2nd param (email for example): 27 | 28 | ```javascript 29 | { 30 | "id": 1, 31 | "jsonrpc": "2.0", 32 | "method": "eth_submitLogin", 33 | "params": ["0xb85150eb365e7df0941f0cf08235f987ba91506a", "admin@example.net"] 34 | } 35 | ``` 36 | 37 | Successful response: 38 | 39 | ```javascript 40 | { "id": 1, "jsonrpc": "2.0", "result": true } 41 | ``` 42 | 43 | Exceptions: 44 | 45 | ```javascript 46 | { "id": 1, "jsonrpc": "2.0", "result": null, "error": { code: -1, message: "Invalid login" } } 47 | ``` 48 | 49 | ## Request For Job 50 | 51 | Request looks like: 52 | 53 | ```javascript 54 | { "id": 1, "jsonrpc": "2.0", "method": "eth_getWork" } 55 | ``` 56 | 57 | Successful response: 58 | 59 | ```javascript 60 | { 61 | "id": 1, 62 | "jsonrpc": "2.0", 63 | "result": [ 64 | "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", 65 | "0x5eed00000000000000000000000000005eed0000000000000000000000000000", 66 | "0xd1ff1c01710000000000000000000000d1ff1c01710000000000000000000000" 67 | ] 68 | } 69 | ``` 70 | 71 | Exceptions: 72 | 73 | ```javascript 74 | { "id": 10, "result": null, "error": { code: 0, message: "Work not ready" } } 75 | ``` 76 | 77 | ## New Job Notification 78 | 79 | Server sends job to peers if new job is available: 80 | 81 | ```javascript 82 | { 83 | "jsonrpc": "2.0", 84 | "result": [ 85 | "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", 86 | "0x5eed00000000000000000000000000005eed0000000000000000000000000000", 87 | "0xd1ff1c01710000000000000000000000d1ff1c01710000000000000000000000" 88 | ] 89 | } 90 | ``` 91 | 92 | ## Share Submission 93 | 94 | Request looks like: 95 | 96 | ```javascript 97 | { 98 | "id": 1, 99 | "jsonrpc": "2.0", 100 | "method": "eth_submitWork", 101 | "params": [ 102 | "0xe05d1fd4002d962f", 103 | "0x6c872e2304cd1e64b553a65387d7383470f22331aff288cbce5748dc430f016a", 104 | "0x2b20a6c641ed155b893ee750ef90ec3be5d24736d16838b84759385b6724220d" 105 | ] 106 | } 107 | ``` 108 | 109 | Request can include optional `worker` param: 110 | 111 | ```javascript 112 | { "id": 1, "worker": "rig-1" /* ... */ } 113 | ``` 114 | 115 | Response: 116 | 117 | ```javascript 118 | { "id": 1, "jsonrpc": "2.0", "result": true } 119 | { "id": 1, "jsonrpc": "2.0", "result": false } 120 | ``` 121 | 122 | Exceptions: 123 | 124 | Pool MAY return exception on invalid share submission usually followed by temporal ban. 125 | 126 | ```javascript 127 | { "id": 1, "jsonrpc": "2.0", "result": null, "error": { code: 23, message: "Invalid share" } } 128 | ``` 129 | 130 | ```javascript 131 | { "id": 1, "jsonrpc": "2.0", "result": null, "error": { code: 22, message: "Duplicate share" } } 132 | { "id": 1, "jsonrpc": "2.0", "result": null, "error": { code: -1, message: "High rate of invalid shares" } } 133 | { "id": 1, "jsonrpc": "2.0", "result": null, "error": { code: 25, message: "Not subscribed" } } 134 | { "id": 1, "jsonrpc": "2.0", "result": null, "error": { code: -1, message: "Malformed PoW result" } } 135 | ``` 136 | 137 | ## Submit Hashrate 138 | 139 | `eth_submitHashrate` is a nonsense method. Pool ignores it and the reply is always: 140 | 141 | ```javascript 142 | { "id": 1, "jsonrpc": "2.0", "result": true } 143 | ``` 144 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // +build go1.9 2 | 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "log" 8 | "math/rand" 9 | "os" 10 | "path/filepath" 11 | "runtime" 12 | "time" 13 | 14 | "github.com/yvasiyarov/gorelic" 15 | 16 | "github.com/sammy007/open-ethereum-pool/api" 17 | "github.com/sammy007/open-ethereum-pool/payouts" 18 | "github.com/sammy007/open-ethereum-pool/proxy" 19 | "github.com/sammy007/open-ethereum-pool/storage" 20 | ) 21 | 22 | var cfg proxy.Config 23 | var backend *storage.RedisClient 24 | 25 | func startProxy() { 26 | s := proxy.NewProxy(&cfg, backend) 27 | s.Start() 28 | } 29 | 30 | func startApi() { 31 | s := api.NewApiServer(&cfg.Api, backend) 32 | s.Start() 33 | } 34 | 35 | func startBlockUnlocker() { 36 | u := payouts.NewBlockUnlocker(&cfg.BlockUnlocker, backend) 37 | u.Start() 38 | } 39 | 40 | func startPayoutsProcessor() { 41 | u := payouts.NewPayoutsProcessor(&cfg.Payouts, backend) 42 | u.Start() 43 | } 44 | 45 | func startNewrelic() { 46 | if cfg.NewrelicEnabled { 47 | nr := gorelic.NewAgent() 48 | nr.Verbose = cfg.NewrelicVerbose 49 | nr.NewrelicLicense = cfg.NewrelicKey 50 | nr.NewrelicName = cfg.NewrelicName 51 | nr.Run() 52 | } 53 | } 54 | 55 | func readConfig(cfg *proxy.Config) { 56 | configFileName := "config.json" 57 | if len(os.Args) > 1 { 58 | configFileName = os.Args[1] 59 | } 60 | configFileName, _ = filepath.Abs(configFileName) 61 | log.Printf("Loading config: %v", configFileName) 62 | 63 | configFile, err := os.Open(configFileName) 64 | if err != nil { 65 | log.Fatal("File error: ", err.Error()) 66 | } 67 | defer configFile.Close() 68 | jsonParser := json.NewDecoder(configFile) 69 | if err := jsonParser.Decode(&cfg); err != nil { 70 | log.Fatal("Config error: ", err.Error()) 71 | } 72 | } 73 | 74 | func main() { 75 | readConfig(&cfg) 76 | rand.Seed(time.Now().UnixNano()) 77 | 78 | if cfg.Threads > 0 { 79 | runtime.GOMAXPROCS(cfg.Threads) 80 | log.Printf("Running with %v threads", cfg.Threads) 81 | } 82 | 83 | startNewrelic() 84 | 85 | backend = storage.NewRedisClient(&cfg.Redis, cfg.Coin) 86 | pong, err := backend.Check() 87 | if err != nil { 88 | log.Printf("Can't establish connection to backend: %v", err) 89 | } else { 90 | log.Printf("Backend check reply: %v", pong) 91 | } 92 | 93 | if cfg.Proxy.Enabled { 94 | go startProxy() 95 | } 96 | if cfg.Api.Enabled { 97 | go startApi() 98 | } 99 | if cfg.BlockUnlocker.Enabled { 100 | go startBlockUnlocker() 101 | } 102 | if cfg.Payouts.Enabled { 103 | go startPayoutsProcessor() 104 | } 105 | quit := make(chan bool) 106 | <-quit 107 | } 108 | -------------------------------------------------------------------------------- /misc/nginx-default.conf: -------------------------------------------------------------------------------- 1 | upstream api { 2 | server 127.0.0.1:8080; 3 | } 4 | 5 | server { 6 | listen 0.0.0.0:80; 7 | root /path/to/pool/www/dist; 8 | index index.html index.htm; 9 | 10 | server_name localhost; 11 | 12 | location /api { 13 | proxy_pass http://api; 14 | } 15 | 16 | location / { 17 | try_files $uri $uri/ /index.html; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /misc/upstart.conf: -------------------------------------------------------------------------------- 1 | # Open-Ethereum-Pool 2 | description "Open-Ethereum-Pool" 3 | 4 | env DAEMON=/home/main/src/open-ethereum-pool/build/bin/open-ethereum-pool 5 | env CONFIG=/home/main/src/open-ethereum-pool/config.json 6 | 7 | start on filesystem or runlevel [2345] 8 | stop on runlevel [!2345] 9 | 10 | setuid main 11 | setgid main 12 | 13 | kill signal INT 14 | 15 | respawn 16 | respawn limit 10 5 17 | umask 022 18 | 19 | pre-start script 20 | test -x $DAEMON || { stop; exit 0; } 21 | end script 22 | 23 | # Start 24 | script 25 | exec $DAEMON $CONFIG 26 | end script 27 | -------------------------------------------------------------------------------- /payouts/payer.go: -------------------------------------------------------------------------------- 1 | package payouts 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math/big" 7 | "os" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/ethereum/go-ethereum/common/hexutil" 12 | 13 | "github.com/sammy007/open-ethereum-pool/rpc" 14 | "github.com/sammy007/open-ethereum-pool/storage" 15 | "github.com/sammy007/open-ethereum-pool/util" 16 | ) 17 | 18 | const txCheckInterval = 5 * time.Second 19 | 20 | type PayoutsConfig struct { 21 | Enabled bool `json:"enabled"` 22 | RequirePeers int64 `json:"requirePeers"` 23 | Interval string `json:"interval"` 24 | Daemon string `json:"daemon"` 25 | Timeout string `json:"timeout"` 26 | Address string `json:"address"` 27 | Gas string `json:"gas"` 28 | GasPrice string `json:"gasPrice"` 29 | AutoGas bool `json:"autoGas"` 30 | // In Shannon 31 | Threshold int64 `json:"threshold"` 32 | BgSave bool `json:"bgsave"` 33 | } 34 | 35 | func (self PayoutsConfig) GasHex() string { 36 | x := util.String2Big(self.Gas) 37 | return hexutil.EncodeBig(x) 38 | } 39 | 40 | func (self PayoutsConfig) GasPriceHex() string { 41 | x := util.String2Big(self.GasPrice) 42 | return hexutil.EncodeBig(x) 43 | } 44 | 45 | type PayoutsProcessor struct { 46 | config *PayoutsConfig 47 | backend *storage.RedisClient 48 | rpc *rpc.RPCClient 49 | halt bool 50 | lastFail error 51 | } 52 | 53 | func NewPayoutsProcessor(cfg *PayoutsConfig, backend *storage.RedisClient) *PayoutsProcessor { 54 | u := &PayoutsProcessor{config: cfg, backend: backend} 55 | u.rpc = rpc.NewRPCClient("PayoutsProcessor", cfg.Daemon, cfg.Timeout) 56 | return u 57 | } 58 | 59 | func (u *PayoutsProcessor) Start() { 60 | log.Println("Starting payouts") 61 | 62 | if u.mustResolvePayout() { 63 | log.Println("Running with env RESOLVE_PAYOUT=1, now trying to resolve locked payouts") 64 | u.resolvePayouts() 65 | log.Println("Now you have to restart payouts module with RESOLVE_PAYOUT=0 for normal run") 66 | return 67 | } 68 | 69 | intv := util.MustParseDuration(u.config.Interval) 70 | timer := time.NewTimer(intv) 71 | log.Printf("Set payouts interval to %v", intv) 72 | 73 | payments := u.backend.GetPendingPayments() 74 | if len(payments) > 0 { 75 | log.Printf("Previous payout failed, you have to resolve it. List of failed payments:\n %v", 76 | formatPendingPayments(payments)) 77 | return 78 | } 79 | 80 | locked, err := u.backend.IsPayoutsLocked() 81 | if err != nil { 82 | log.Println("Unable to start payouts:", err) 83 | return 84 | } 85 | if locked { 86 | log.Println("Unable to start payouts because they are locked") 87 | return 88 | } 89 | 90 | // Immediately process payouts after start 91 | u.process() 92 | timer.Reset(intv) 93 | 94 | go func() { 95 | for { 96 | select { 97 | case <-timer.C: 98 | u.process() 99 | timer.Reset(intv) 100 | } 101 | } 102 | }() 103 | } 104 | 105 | func (u *PayoutsProcessor) process() { 106 | if u.halt { 107 | log.Println("Payments suspended due to last critical error:", u.lastFail) 108 | return 109 | } 110 | mustPay := 0 111 | minersPaid := 0 112 | totalAmount := big.NewInt(0) 113 | payees, err := u.backend.GetPayees() 114 | if err != nil { 115 | log.Println("Error while retrieving payees from backend:", err) 116 | return 117 | } 118 | 119 | for _, login := range payees { 120 | amount, _ := u.backend.GetBalance(login) 121 | amountInShannon := big.NewInt(amount) 122 | 123 | // Shannon^2 = Wei 124 | amountInWei := new(big.Int).Mul(amountInShannon, util.Shannon) 125 | 126 | if !u.reachedThreshold(amountInShannon) { 127 | continue 128 | } 129 | mustPay++ 130 | 131 | // Require active peers before processing 132 | if !u.checkPeers() { 133 | break 134 | } 135 | // Require unlocked account 136 | if !u.isUnlockedAccount() { 137 | break 138 | } 139 | 140 | // Check if we have enough funds 141 | poolBalance, err := u.rpc.GetBalance(u.config.Address) 142 | if err != nil { 143 | u.halt = true 144 | u.lastFail = err 145 | break 146 | } 147 | if poolBalance.Cmp(amountInWei) < 0 { 148 | err := fmt.Errorf("Not enough balance for payment, need %s Wei, pool has %s Wei", 149 | amountInWei.String(), poolBalance.String()) 150 | u.halt = true 151 | u.lastFail = err 152 | break 153 | } 154 | 155 | // Lock payments for current payout 156 | err = u.backend.LockPayouts(login, amount) 157 | if err != nil { 158 | log.Printf("Failed to lock payment for %s: %v", login, err) 159 | u.halt = true 160 | u.lastFail = err 161 | break 162 | } 163 | log.Printf("Locked payment for %s, %v Shannon", login, amount) 164 | 165 | // Debit miner's balance and update stats 166 | err = u.backend.UpdateBalance(login, amount) 167 | if err != nil { 168 | log.Printf("Failed to update balance for %s, %v Shannon: %v", login, amount, err) 169 | u.halt = true 170 | u.lastFail = err 171 | break 172 | } 173 | 174 | value := hexutil.EncodeBig(amountInWei) 175 | txHash, err := u.rpc.SendTransaction(u.config.Address, login, u.config.GasHex(), u.config.GasPriceHex(), value, u.config.AutoGas) 176 | if err != nil { 177 | log.Printf("Failed to send payment to %s, %v Shannon: %v. Check outgoing tx for %s in block explorer and docs/PAYOUTS.md", 178 | login, amount, err, login) 179 | u.halt = true 180 | u.lastFail = err 181 | break 182 | } 183 | 184 | // Log transaction hash 185 | err = u.backend.WritePayment(login, txHash, amount) 186 | if err != nil { 187 | log.Printf("Failed to log payment data for %s, %v Shannon, tx: %s: %v", login, amount, txHash, err) 188 | u.halt = true 189 | u.lastFail = err 190 | break 191 | } 192 | 193 | minersPaid++ 194 | totalAmount.Add(totalAmount, big.NewInt(amount)) 195 | log.Printf("Paid %v Shannon to %v, TxHash: %v", amount, login, txHash) 196 | 197 | // Wait for TX confirmation before further payouts 198 | for { 199 | log.Printf("Waiting for tx confirmation: %v", txHash) 200 | time.Sleep(txCheckInterval) 201 | receipt, err := u.rpc.GetTxReceipt(txHash) 202 | if err != nil { 203 | log.Printf("Failed to get tx receipt for %v: %v", txHash, err) 204 | continue 205 | } 206 | // Tx has been mined 207 | if receipt != nil && receipt.Confirmed() { 208 | if receipt.Successful() { 209 | log.Printf("Payout tx successful for %s: %s", login, txHash) 210 | } else { 211 | log.Printf("Payout tx failed for %s: %s. Address contract throws on incoming tx.", login, txHash) 212 | } 213 | break 214 | } 215 | } 216 | } 217 | 218 | if mustPay > 0 { 219 | log.Printf("Paid total %v Shannon to %v of %v payees", totalAmount, minersPaid, mustPay) 220 | } else { 221 | log.Println("No payees that have reached payout threshold") 222 | } 223 | 224 | // Save redis state to disk 225 | if minersPaid > 0 && u.config.BgSave { 226 | u.bgSave() 227 | } 228 | } 229 | 230 | func (self PayoutsProcessor) isUnlockedAccount() bool { 231 | _, err := self.rpc.Sign(self.config.Address, "0x0") 232 | if err != nil { 233 | log.Println("Unable to process payouts:", err) 234 | return false 235 | } 236 | return true 237 | } 238 | 239 | func (self PayoutsProcessor) checkPeers() bool { 240 | n, err := self.rpc.GetPeerCount() 241 | if err != nil { 242 | log.Println("Unable to start payouts, failed to retrieve number of peers from node:", err) 243 | return false 244 | } 245 | if n < self.config.RequirePeers { 246 | log.Println("Unable to start payouts, number of peers on a node is less than required", self.config.RequirePeers) 247 | return false 248 | } 249 | return true 250 | } 251 | 252 | func (self PayoutsProcessor) reachedThreshold(amount *big.Int) bool { 253 | return big.NewInt(self.config.Threshold).Cmp(amount) < 0 254 | } 255 | 256 | func formatPendingPayments(list []*storage.PendingPayment) string { 257 | var s string 258 | for _, v := range list { 259 | s += fmt.Sprintf("\tAddress: %s, Amount: %v Shannon, %v\n", v.Address, v.Amount, time.Unix(v.Timestamp, 0)) 260 | } 261 | return s 262 | } 263 | 264 | func (self PayoutsProcessor) bgSave() { 265 | result, err := self.backend.BgSave() 266 | if err != nil { 267 | log.Println("Failed to perform BGSAVE on backend:", err) 268 | return 269 | } 270 | log.Println("Saving backend state to disk:", result) 271 | } 272 | 273 | func (self PayoutsProcessor) resolvePayouts() { 274 | payments := self.backend.GetPendingPayments() 275 | 276 | if len(payments) > 0 { 277 | log.Printf("Will credit back following balances:\n%s", formatPendingPayments(payments)) 278 | 279 | for _, v := range payments { 280 | err := self.backend.RollbackBalance(v.Address, v.Amount) 281 | if err != nil { 282 | log.Printf("Failed to credit %v Shannon back to %s, error is: %v", v.Amount, v.Address, err) 283 | return 284 | } 285 | log.Printf("Credited %v Shannon back to %s", v.Amount, v.Address) 286 | } 287 | err := self.backend.UnlockPayouts() 288 | if err != nil { 289 | log.Println("Failed to unlock payouts:", err) 290 | return 291 | } 292 | } else { 293 | log.Println("No pending payments to resolve") 294 | } 295 | 296 | if self.config.BgSave { 297 | self.bgSave() 298 | } 299 | log.Println("Payouts unlocked") 300 | } 301 | 302 | func (self PayoutsProcessor) mustResolvePayout() bool { 303 | v, _ := strconv.ParseBool(os.Getenv("RESOLVE_PAYOUT")) 304 | return v 305 | } 306 | -------------------------------------------------------------------------------- /payouts/unlocker.go: -------------------------------------------------------------------------------- 1 | package payouts 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math/big" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/ethereum/go-ethereum/common/math" 12 | 13 | "github.com/sammy007/open-ethereum-pool/rpc" 14 | "github.com/sammy007/open-ethereum-pool/storage" 15 | "github.com/sammy007/open-ethereum-pool/util" 16 | ) 17 | 18 | type UnlockerConfig struct { 19 | Enabled bool `json:"enabled"` 20 | PoolFee float64 `json:"poolFee"` 21 | PoolFeeAddress string `json:"poolFeeAddress"` 22 | Donate bool `json:"donate"` 23 | Depth int64 `json:"depth"` 24 | ImmatureDepth int64 `json:"immatureDepth"` 25 | KeepTxFees bool `json:"keepTxFees"` 26 | Interval string `json:"interval"` 27 | Daemon string `json:"daemon"` 28 | Timeout string `json:"timeout"` 29 | } 30 | 31 | const minDepth = 16 32 | const byzantiumHardForkHeight = 4370000 33 | 34 | var homesteadReward = math.MustParseBig256("5000000000000000000") 35 | var byzantiumReward = math.MustParseBig256("3000000000000000000") 36 | 37 | // Donate 10% from pool fees to developers 38 | const donationFee = 10.0 39 | const donationAccount = "0xb85150eb365e7df0941f0cf08235f987ba91506a" 40 | 41 | type BlockUnlocker struct { 42 | config *UnlockerConfig 43 | backend *storage.RedisClient 44 | rpc *rpc.RPCClient 45 | halt bool 46 | lastFail error 47 | } 48 | 49 | func NewBlockUnlocker(cfg *UnlockerConfig, backend *storage.RedisClient) *BlockUnlocker { 50 | if len(cfg.PoolFeeAddress) != 0 && !util.IsValidHexAddress(cfg.PoolFeeAddress) { 51 | log.Fatalln("Invalid poolFeeAddress", cfg.PoolFeeAddress) 52 | } 53 | if cfg.Depth < minDepth*2 { 54 | log.Fatalf("Block maturity depth can't be < %v, your depth is %v", minDepth*2, cfg.Depth) 55 | } 56 | if cfg.ImmatureDepth < minDepth { 57 | log.Fatalf("Immature depth can't be < %v, your depth is %v", minDepth, cfg.ImmatureDepth) 58 | } 59 | u := &BlockUnlocker{config: cfg, backend: backend} 60 | u.rpc = rpc.NewRPCClient("BlockUnlocker", cfg.Daemon, cfg.Timeout) 61 | return u 62 | } 63 | 64 | func (u *BlockUnlocker) Start() { 65 | log.Println("Starting block unlocker") 66 | intv := util.MustParseDuration(u.config.Interval) 67 | timer := time.NewTimer(intv) 68 | log.Printf("Set block unlock interval to %v", intv) 69 | 70 | // Immediately unlock after start 71 | u.unlockPendingBlocks() 72 | u.unlockAndCreditMiners() 73 | timer.Reset(intv) 74 | 75 | go func() { 76 | for { 77 | select { 78 | case <-timer.C: 79 | u.unlockPendingBlocks() 80 | u.unlockAndCreditMiners() 81 | timer.Reset(intv) 82 | } 83 | } 84 | }() 85 | } 86 | 87 | type UnlockResult struct { 88 | maturedBlocks []*storage.BlockData 89 | orphanedBlocks []*storage.BlockData 90 | orphans int 91 | uncles int 92 | blocks int 93 | } 94 | 95 | /* Geth does not provide consistent state when you need both new height and new job, 96 | * so in redis I am logging just what I have in a pool state on the moment when block found. 97 | * Having very likely incorrect height in database results in a weird block unlocking scheme, 98 | * when I have to check what the hell we actually found and traversing all the blocks with height-N and height+N 99 | * to make sure we will find it. We can't rely on round height here, it's just a reference point. 100 | * ISSUE: https://github.com/ethereum/go-ethereum/issues/2333 101 | */ 102 | func (u *BlockUnlocker) unlockCandidates(candidates []*storage.BlockData) (*UnlockResult, error) { 103 | result := &UnlockResult{} 104 | 105 | // Data row is: "height:nonce:powHash:mixDigest:timestamp:diff:totalShares" 106 | for _, candidate := range candidates { 107 | orphan := true 108 | 109 | /* Search for a normal block with wrong height here by traversing 16 blocks back and forward. 110 | * Also we are searching for a block that can include this one as uncle. 111 | */ 112 | for i := int64(minDepth * -1); i < minDepth; i++ { 113 | height := candidate.Height + i 114 | 115 | if height < 0 { 116 | continue 117 | } 118 | 119 | block, err := u.rpc.GetBlockByHeight(height) 120 | if err != nil { 121 | log.Printf("Error while retrieving block %v from node: %v", height, err) 122 | return nil, err 123 | } 124 | if block == nil { 125 | return nil, fmt.Errorf("Error while retrieving block %v from node, wrong node height", height) 126 | } 127 | 128 | if matchCandidate(block, candidate) { 129 | orphan = false 130 | result.blocks++ 131 | 132 | err = u.handleBlock(block, candidate) 133 | if err != nil { 134 | u.halt = true 135 | u.lastFail = err 136 | return nil, err 137 | } 138 | result.maturedBlocks = append(result.maturedBlocks, candidate) 139 | log.Printf("Mature block %v with %v tx, hash: %v", candidate.Height, len(block.Transactions), candidate.Hash[0:10]) 140 | break 141 | } 142 | 143 | if len(block.Uncles) == 0 { 144 | continue 145 | } 146 | 147 | // Trying to find uncle in current block during our forward check 148 | for uncleIndex, uncleHash := range block.Uncles { 149 | uncle, err := u.rpc.GetUncleByBlockNumberAndIndex(height, uncleIndex) 150 | if err != nil { 151 | return nil, fmt.Errorf("Error while retrieving uncle of block %v from node: %v", uncleHash, err) 152 | } 153 | if uncle == nil { 154 | return nil, fmt.Errorf("Error while retrieving uncle of block %v from node", height) 155 | } 156 | 157 | // Found uncle 158 | if matchCandidate(uncle, candidate) { 159 | orphan = false 160 | result.uncles++ 161 | 162 | err := handleUncle(height, uncle, candidate) 163 | if err != nil { 164 | u.halt = true 165 | u.lastFail = err 166 | return nil, err 167 | } 168 | result.maturedBlocks = append(result.maturedBlocks, candidate) 169 | log.Printf("Mature uncle %v/%v of reward %v with hash: %v", candidate.Height, candidate.UncleHeight, 170 | util.FormatReward(candidate.Reward), uncle.Hash[0:10]) 171 | break 172 | } 173 | } 174 | // Found block or uncle 175 | if !orphan { 176 | break 177 | } 178 | } 179 | // Block is lost, we didn't find any valid block or uncle matching our data in a blockchain 180 | if orphan { 181 | result.orphans++ 182 | candidate.Orphan = true 183 | result.orphanedBlocks = append(result.orphanedBlocks, candidate) 184 | log.Printf("Orphaned block %v:%v", candidate.RoundHeight, candidate.Nonce) 185 | } 186 | } 187 | return result, nil 188 | } 189 | 190 | func matchCandidate(block *rpc.GetBlockReply, candidate *storage.BlockData) bool { 191 | // Just compare hash if block is unlocked as immature 192 | if len(candidate.Hash) > 0 && strings.EqualFold(candidate.Hash, block.Hash) { 193 | return true 194 | } 195 | // Geth-style candidate matching 196 | if len(block.Nonce) > 0 { 197 | return strings.EqualFold(block.Nonce, candidate.Nonce) 198 | } 199 | // Parity's EIP: https://github.com/ethereum/EIPs/issues/95 200 | if len(block.SealFields) == 2 { 201 | return strings.EqualFold(candidate.Nonce, block.SealFields[1]) 202 | } 203 | return false 204 | } 205 | 206 | func (u *BlockUnlocker) handleBlock(block *rpc.GetBlockReply, candidate *storage.BlockData) error { 207 | correctHeight, err := strconv.ParseInt(strings.Replace(block.Number, "0x", "", -1), 16, 64) 208 | if err != nil { 209 | return err 210 | } 211 | candidate.Height = correctHeight 212 | reward := getConstReward(candidate.Height) 213 | 214 | // Add TX fees 215 | extraTxReward, err := u.getExtraRewardForTx(block) 216 | if err != nil { 217 | return fmt.Errorf("Error while fetching TX receipt: %v", err) 218 | } 219 | if u.config.KeepTxFees { 220 | candidate.ExtraReward = extraTxReward 221 | } else { 222 | reward.Add(reward, extraTxReward) 223 | } 224 | 225 | // Add reward for including uncles 226 | uncleReward := getRewardForUncle(candidate.Height) 227 | rewardForUncles := big.NewInt(0).Mul(uncleReward, big.NewInt(int64(len(block.Uncles)))) 228 | reward.Add(reward, rewardForUncles) 229 | 230 | candidate.Orphan = false 231 | candidate.Hash = block.Hash 232 | candidate.Reward = reward 233 | return nil 234 | } 235 | 236 | func handleUncle(height int64, uncle *rpc.GetBlockReply, candidate *storage.BlockData) error { 237 | uncleHeight, err := strconv.ParseInt(strings.Replace(uncle.Number, "0x", "", -1), 16, 64) 238 | if err != nil { 239 | return err 240 | } 241 | reward := getUncleReward(uncleHeight, height) 242 | candidate.Height = height 243 | candidate.UncleHeight = uncleHeight 244 | candidate.Orphan = false 245 | candidate.Hash = uncle.Hash 246 | candidate.Reward = reward 247 | return nil 248 | } 249 | 250 | func (u *BlockUnlocker) unlockPendingBlocks() { 251 | if u.halt { 252 | log.Println("Unlocking suspended due to last critical error:", u.lastFail) 253 | return 254 | } 255 | 256 | current, err := u.rpc.GetPendingBlock() 257 | if err != nil { 258 | u.halt = true 259 | u.lastFail = err 260 | log.Printf("Unable to get current blockchain height from node: %v", err) 261 | return 262 | } 263 | currentHeight, err := strconv.ParseInt(strings.Replace(current.Number, "0x", "", -1), 16, 64) 264 | if err != nil { 265 | u.halt = true 266 | u.lastFail = err 267 | log.Printf("Can't parse pending block number: %v", err) 268 | return 269 | } 270 | 271 | candidates, err := u.backend.GetCandidates(currentHeight - u.config.ImmatureDepth) 272 | if err != nil { 273 | u.halt = true 274 | u.lastFail = err 275 | log.Printf("Failed to get block candidates from backend: %v", err) 276 | return 277 | } 278 | 279 | if len(candidates) == 0 { 280 | log.Println("No block candidates to unlock") 281 | return 282 | } 283 | 284 | result, err := u.unlockCandidates(candidates) 285 | if err != nil { 286 | u.halt = true 287 | u.lastFail = err 288 | log.Printf("Failed to unlock blocks: %v", err) 289 | return 290 | } 291 | log.Printf("Immature %v blocks, %v uncles, %v orphans", result.blocks, result.uncles, result.orphans) 292 | 293 | err = u.backend.WritePendingOrphans(result.orphanedBlocks) 294 | if err != nil { 295 | u.halt = true 296 | u.lastFail = err 297 | log.Printf("Failed to insert orphaned blocks into backend: %v", err) 298 | return 299 | } else { 300 | log.Printf("Inserted %v orphaned blocks to backend", result.orphans) 301 | } 302 | 303 | totalRevenue := new(big.Rat) 304 | totalMinersProfit := new(big.Rat) 305 | totalPoolProfit := new(big.Rat) 306 | 307 | for _, block := range result.maturedBlocks { 308 | revenue, minersProfit, poolProfit, roundRewards, err := u.calculateRewards(block) 309 | if err != nil { 310 | u.halt = true 311 | u.lastFail = err 312 | log.Printf("Failed to calculate rewards for round %v: %v", block.RoundKey(), err) 313 | return 314 | } 315 | err = u.backend.WriteImmatureBlock(block, roundRewards) 316 | if err != nil { 317 | u.halt = true 318 | u.lastFail = err 319 | log.Printf("Failed to credit rewards for round %v: %v", block.RoundKey(), err) 320 | return 321 | } 322 | totalRevenue.Add(totalRevenue, revenue) 323 | totalMinersProfit.Add(totalMinersProfit, minersProfit) 324 | totalPoolProfit.Add(totalPoolProfit, poolProfit) 325 | 326 | logEntry := fmt.Sprintf( 327 | "IMMATURE %v: revenue %v, miners profit %v, pool profit: %v", 328 | block.RoundKey(), 329 | util.FormatRatReward(revenue), 330 | util.FormatRatReward(minersProfit), 331 | util.FormatRatReward(poolProfit), 332 | ) 333 | entries := []string{logEntry} 334 | for login, reward := range roundRewards { 335 | entries = append(entries, fmt.Sprintf("\tREWARD %v: %v: %v Shannon", block.RoundKey(), login, reward)) 336 | } 337 | log.Println(strings.Join(entries, "\n")) 338 | } 339 | 340 | log.Printf( 341 | "IMMATURE SESSION: revenue %v, miners profit %v, pool profit: %v", 342 | util.FormatRatReward(totalRevenue), 343 | util.FormatRatReward(totalMinersProfit), 344 | util.FormatRatReward(totalPoolProfit), 345 | ) 346 | } 347 | 348 | func (u *BlockUnlocker) unlockAndCreditMiners() { 349 | if u.halt { 350 | log.Println("Unlocking suspended due to last critical error:", u.lastFail) 351 | return 352 | } 353 | 354 | current, err := u.rpc.GetPendingBlock() 355 | if err != nil { 356 | u.halt = true 357 | u.lastFail = err 358 | log.Printf("Unable to get current blockchain height from node: %v", err) 359 | return 360 | } 361 | currentHeight, err := strconv.ParseInt(strings.Replace(current.Number, "0x", "", -1), 16, 64) 362 | if err != nil { 363 | u.halt = true 364 | u.lastFail = err 365 | log.Printf("Can't parse pending block number: %v", err) 366 | return 367 | } 368 | 369 | immature, err := u.backend.GetImmatureBlocks(currentHeight - u.config.Depth) 370 | if err != nil { 371 | u.halt = true 372 | u.lastFail = err 373 | log.Printf("Failed to get block candidates from backend: %v", err) 374 | return 375 | } 376 | 377 | if len(immature) == 0 { 378 | log.Println("No immature blocks to credit miners") 379 | return 380 | } 381 | 382 | result, err := u.unlockCandidates(immature) 383 | if err != nil { 384 | u.halt = true 385 | u.lastFail = err 386 | log.Printf("Failed to unlock blocks: %v", err) 387 | return 388 | } 389 | log.Printf("Unlocked %v blocks, %v uncles, %v orphans", result.blocks, result.uncles, result.orphans) 390 | 391 | for _, block := range result.orphanedBlocks { 392 | err = u.backend.WriteOrphan(block) 393 | if err != nil { 394 | u.halt = true 395 | u.lastFail = err 396 | log.Printf("Failed to insert orphaned block into backend: %v", err) 397 | return 398 | } 399 | } 400 | log.Printf("Inserted %v orphaned blocks to backend", result.orphans) 401 | 402 | totalRevenue := new(big.Rat) 403 | totalMinersProfit := new(big.Rat) 404 | totalPoolProfit := new(big.Rat) 405 | 406 | for _, block := range result.maturedBlocks { 407 | revenue, minersProfit, poolProfit, roundRewards, err := u.calculateRewards(block) 408 | if err != nil { 409 | u.halt = true 410 | u.lastFail = err 411 | log.Printf("Failed to calculate rewards for round %v: %v", block.RoundKey(), err) 412 | return 413 | } 414 | err = u.backend.WriteMaturedBlock(block, roundRewards) 415 | if err != nil { 416 | u.halt = true 417 | u.lastFail = err 418 | log.Printf("Failed to credit rewards for round %v: %v", block.RoundKey(), err) 419 | return 420 | } 421 | totalRevenue.Add(totalRevenue, revenue) 422 | totalMinersProfit.Add(totalMinersProfit, minersProfit) 423 | totalPoolProfit.Add(totalPoolProfit, poolProfit) 424 | 425 | logEntry := fmt.Sprintf( 426 | "MATURED %v: revenue %v, miners profit %v, pool profit: %v", 427 | block.RoundKey(), 428 | util.FormatRatReward(revenue), 429 | util.FormatRatReward(minersProfit), 430 | util.FormatRatReward(poolProfit), 431 | ) 432 | entries := []string{logEntry} 433 | for login, reward := range roundRewards { 434 | entries = append(entries, fmt.Sprintf("\tREWARD %v: %v: %v Shannon", block.RoundKey(), login, reward)) 435 | } 436 | log.Println(strings.Join(entries, "\n")) 437 | } 438 | 439 | log.Printf( 440 | "MATURE SESSION: revenue %v, miners profit %v, pool profit: %v", 441 | util.FormatRatReward(totalRevenue), 442 | util.FormatRatReward(totalMinersProfit), 443 | util.FormatRatReward(totalPoolProfit), 444 | ) 445 | } 446 | 447 | func (u *BlockUnlocker) calculateRewards(block *storage.BlockData) (*big.Rat, *big.Rat, *big.Rat, map[string]int64, error) { 448 | revenue := new(big.Rat).SetInt(block.Reward) 449 | minersProfit, poolProfit := chargeFee(revenue, u.config.PoolFee) 450 | 451 | shares, err := u.backend.GetRoundShares(block.RoundHeight, block.Nonce) 452 | if err != nil { 453 | return nil, nil, nil, nil, err 454 | } 455 | 456 | rewards := calculateRewardsForShares(shares, block.TotalShares, minersProfit) 457 | 458 | if block.ExtraReward != nil { 459 | extraReward := new(big.Rat).SetInt(block.ExtraReward) 460 | poolProfit.Add(poolProfit, extraReward) 461 | revenue.Add(revenue, extraReward) 462 | } 463 | 464 | if u.config.Donate { 465 | var donation = new(big.Rat) 466 | poolProfit, donation = chargeFee(poolProfit, donationFee) 467 | login := strings.ToLower(donationAccount) 468 | rewards[login] += weiToShannonInt64(donation) 469 | } 470 | 471 | if len(u.config.PoolFeeAddress) != 0 { 472 | address := strings.ToLower(u.config.PoolFeeAddress) 473 | rewards[address] += weiToShannonInt64(poolProfit) 474 | } 475 | 476 | return revenue, minersProfit, poolProfit, rewards, nil 477 | } 478 | 479 | func calculateRewardsForShares(shares map[string]int64, total int64, reward *big.Rat) map[string]int64 { 480 | rewards := make(map[string]int64) 481 | 482 | for login, n := range shares { 483 | percent := big.NewRat(n, total) 484 | workerReward := new(big.Rat).Mul(reward, percent) 485 | rewards[login] += weiToShannonInt64(workerReward) 486 | } 487 | return rewards 488 | } 489 | 490 | // Returns new value after fee deduction and fee value. 491 | func chargeFee(value *big.Rat, fee float64) (*big.Rat, *big.Rat) { 492 | feePercent := new(big.Rat).SetFloat64(fee / 100) 493 | feeValue := new(big.Rat).Mul(value, feePercent) 494 | return new(big.Rat).Sub(value, feeValue), feeValue 495 | } 496 | 497 | func weiToShannonInt64(wei *big.Rat) int64 { 498 | shannon := new(big.Rat).SetInt(util.Shannon) 499 | inShannon := new(big.Rat).Quo(wei, shannon) 500 | value, _ := strconv.ParseInt(inShannon.FloatString(0), 10, 64) 501 | return value 502 | } 503 | 504 | func getConstReward(height int64) *big.Int { 505 | if height >= byzantiumHardForkHeight { 506 | return new(big.Int).Set(byzantiumReward) 507 | } 508 | return new(big.Int).Set(homesteadReward) 509 | } 510 | 511 | func getRewardForUncle(height int64) *big.Int { 512 | reward := getConstReward(height) 513 | return new(big.Int).Div(reward, new(big.Int).SetInt64(32)) 514 | } 515 | 516 | func getUncleReward(uHeight, height int64) *big.Int { 517 | reward := getConstReward(height) 518 | k := height - uHeight 519 | reward.Mul(big.NewInt(8-k), reward) 520 | reward.Div(reward, big.NewInt(8)) 521 | return reward 522 | } 523 | 524 | func (u *BlockUnlocker) getExtraRewardForTx(block *rpc.GetBlockReply) (*big.Int, error) { 525 | amount := new(big.Int) 526 | 527 | for _, tx := range block.Transactions { 528 | receipt, err := u.rpc.GetTxReceipt(tx.Hash) 529 | if err != nil { 530 | return nil, err 531 | } 532 | if receipt != nil { 533 | gasUsed := util.String2Big(receipt.GasUsed) 534 | gasPrice := util.String2Big(tx.GasPrice) 535 | fee := new(big.Int).Mul(gasUsed, gasPrice) 536 | amount.Add(amount, fee) 537 | } 538 | } 539 | return amount, nil 540 | } 541 | -------------------------------------------------------------------------------- /payouts/unlocker_test.go: -------------------------------------------------------------------------------- 1 | package payouts 2 | 3 | import ( 4 | "math/big" 5 | "os" 6 | "testing" 7 | 8 | "github.com/sammy007/open-ethereum-pool/rpc" 9 | "github.com/sammy007/open-ethereum-pool/storage" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | os.Exit(m.Run()) 14 | } 15 | 16 | func TestCalculateRewards(t *testing.T) { 17 | blockReward, _ := new(big.Rat).SetString("5000000000000000000") 18 | shares := map[string]int64{"0x0": 1000000, "0x1": 20000, "0x2": 5000, "0x3": 10, "0x4": 1} 19 | expectedRewards := map[string]int64{"0x0": 4877996431, "0x1": 97559929, "0x2": 24389982, "0x3": 48780, "0x4": 4878} 20 | totalShares := int64(1025011) 21 | 22 | rewards := calculateRewardsForShares(shares, totalShares, blockReward) 23 | expectedTotalAmount := int64(5000000000) 24 | 25 | totalAmount := int64(0) 26 | for login, amount := range rewards { 27 | totalAmount += amount 28 | 29 | if expectedRewards[login] != amount { 30 | t.Errorf("Amount for %v must be equal to %v vs %v", login, expectedRewards[login], amount) 31 | } 32 | } 33 | if totalAmount != expectedTotalAmount { 34 | t.Errorf("Total reward must be equal to block reward in Shannon: %v vs %v", expectedTotalAmount, totalAmount) 35 | } 36 | } 37 | 38 | func TestChargeFee(t *testing.T) { 39 | orig, _ := new(big.Rat).SetString("5000000000000000000") 40 | value, _ := new(big.Rat).SetString("5000000000000000000") 41 | expectedNewValue, _ := new(big.Rat).SetString("3750000000000000000") 42 | expectedFee, _ := new(big.Rat).SetString("1250000000000000000") 43 | newValue, fee := chargeFee(orig, 25.0) 44 | 45 | if orig.Cmp(value) != 0 { 46 | t.Error("Must not change original value") 47 | } 48 | if newValue.Cmp(expectedNewValue) != 0 { 49 | t.Error("Must charge and deduct correct fee") 50 | } 51 | if fee.Cmp(expectedFee) != 0 { 52 | t.Error("Must charge fee") 53 | } 54 | } 55 | 56 | func TestWeiToShannonInt64(t *testing.T) { 57 | wei, _ := new(big.Rat).SetString("1000000000000000000") 58 | origWei, _ := new(big.Rat).SetString("1000000000000000000") 59 | shannon := int64(1000000000) 60 | 61 | if weiToShannonInt64(wei) != shannon { 62 | t.Error("Must convert to Shannon") 63 | } 64 | if wei.Cmp(origWei) != 0 { 65 | t.Error("Must charge original value") 66 | } 67 | } 68 | 69 | func TestGetUncleReward(t *testing.T) { 70 | rewards := make(map[int64]string) 71 | expectedRewards := map[int64]string{ 72 | 1: "4375000000000000000", 73 | 2: "3750000000000000000", 74 | 3: "3125000000000000000", 75 | 4: "2500000000000000000", 76 | 5: "1875000000000000000", 77 | 6: "1250000000000000000", 78 | 7: "625000000000000000", 79 | } 80 | for i := int64(1); i < 8; i++ { 81 | rewards[i] = getUncleReward(1, i+1).String() 82 | } 83 | for i, reward := range rewards { 84 | if expectedRewards[i] != rewards[i] { 85 | t.Errorf("Incorrect uncle reward for %v, expected %v vs %v", i, expectedRewards[i], reward) 86 | } 87 | } 88 | } 89 | 90 | func TestGetByzantiumUncleReward(t *testing.T) { 91 | rewards := make(map[int64]string) 92 | expectedRewards := map[int64]string{ 93 | 1: "2625000000000000000", 94 | 2: "2250000000000000000", 95 | 3: "1875000000000000000", 96 | 4: "1500000000000000000", 97 | 5: "1125000000000000000", 98 | 6: "750000000000000000", 99 | 7: "375000000000000000", 100 | } 101 | for i := int64(1); i < 8; i++ { 102 | rewards[i] = getUncleReward(byzantiumHardForkHeight, byzantiumHardForkHeight+i).String() 103 | } 104 | for i, reward := range rewards { 105 | if expectedRewards[i] != rewards[i] { 106 | t.Errorf("Incorrect uncle reward for %v, expected %v vs %v", i, expectedRewards[i], reward) 107 | } 108 | } 109 | } 110 | 111 | func TestGetRewardForUngle(t *testing.T) { 112 | reward := getRewardForUncle(1).String() 113 | expectedReward := "156250000000000000" 114 | if expectedReward != reward { 115 | t.Errorf("Incorrect uncle bonus for height %v, expected %v vs %v", 1, expectedReward, reward) 116 | } 117 | } 118 | 119 | func TestGetByzantiumRewardForUngle(t *testing.T) { 120 | reward := getRewardForUncle(byzantiumHardForkHeight).String() 121 | expectedReward := "93750000000000000" 122 | if expectedReward != reward { 123 | t.Errorf("Incorrect uncle bonus for height %v, expected %v vs %v", byzantiumHardForkHeight, expectedReward, reward) 124 | } 125 | } 126 | 127 | func TestMatchCandidate(t *testing.T) { 128 | gethBlock := &rpc.GetBlockReply{Hash: "0x12345A", Nonce: "0x1A"} 129 | parityBlock := &rpc.GetBlockReply{Hash: "0x12345A", SealFields: []string{"0x0A", "0x1A"}} 130 | candidate := &storage.BlockData{Nonce: "0x1a"} 131 | orphan := &storage.BlockData{Nonce: "0x1abc"} 132 | 133 | if !matchCandidate(gethBlock, candidate) { 134 | t.Error("Must match with nonce") 135 | } 136 | if !matchCandidate(parityBlock, candidate) { 137 | t.Error("Must match with seal fields") 138 | } 139 | if matchCandidate(gethBlock, orphan) { 140 | t.Error("Must not match with orphan with nonce") 141 | } 142 | if matchCandidate(parityBlock, orphan) { 143 | t.Error("Must not match orphan with seal fields") 144 | } 145 | 146 | block := &rpc.GetBlockReply{Hash: "0x12345A"} 147 | immature := &storage.BlockData{Hash: "0x12345a", Nonce: "0x0"} 148 | if !matchCandidate(block, immature) { 149 | t.Error("Must match with hash") 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /policy/policy.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os/exec" 7 | "strings" 8 | "sync" 9 | "sync/atomic" 10 | "time" 11 | 12 | "github.com/sammy007/open-ethereum-pool/storage" 13 | "github.com/sammy007/open-ethereum-pool/util" 14 | ) 15 | 16 | type Config struct { 17 | Workers int `json:"workers"` 18 | Banning Banning `json:"banning"` 19 | Limits Limits `json:"limits"` 20 | ResetInterval string `json:"resetInterval"` 21 | RefreshInterval string `json:"refreshInterval"` 22 | } 23 | 24 | type Limits struct { 25 | Enabled bool `json:"enabled"` 26 | Limit int32 `json:"limit"` 27 | Grace string `json:"grace"` 28 | LimitJump int32 `json:"limitJump"` 29 | } 30 | 31 | type Banning struct { 32 | Enabled bool `json:"enabled"` 33 | IPSet string `json:"ipset"` 34 | Timeout int64 `json:"timeout"` 35 | InvalidPercent float32 `json:"invalidPercent"` 36 | CheckThreshold int32 `json:"checkThreshold"` 37 | MalformedLimit int32 `json:"malformedLimit"` 38 | } 39 | 40 | type Stats struct { 41 | sync.Mutex 42 | // We are using atomic with LastBeat, 43 | // so moving it before the rest in order to avoid alignment issue 44 | LastBeat int64 45 | BannedAt int64 46 | ValidShares int32 47 | InvalidShares int32 48 | Malformed int32 49 | ConnLimit int32 50 | Banned int32 51 | } 52 | 53 | type PolicyServer struct { 54 | sync.RWMutex 55 | statsMu sync.Mutex 56 | config *Config 57 | stats map[string]*Stats 58 | banChannel chan string 59 | startedAt int64 60 | grace int64 61 | timeout int64 62 | blacklist []string 63 | whitelist []string 64 | storage *storage.RedisClient 65 | } 66 | 67 | func Start(cfg *Config, storage *storage.RedisClient) *PolicyServer { 68 | s := &PolicyServer{config: cfg, startedAt: util.MakeTimestamp()} 69 | grace := util.MustParseDuration(cfg.Limits.Grace) 70 | s.grace = int64(grace / time.Millisecond) 71 | s.banChannel = make(chan string, 64) 72 | s.stats = make(map[string]*Stats) 73 | s.storage = storage 74 | s.refreshState() 75 | 76 | timeout := util.MustParseDuration(s.config.ResetInterval) 77 | s.timeout = int64(timeout / time.Millisecond) 78 | 79 | resetIntv := util.MustParseDuration(s.config.ResetInterval) 80 | resetTimer := time.NewTimer(resetIntv) 81 | log.Printf("Set policy stats reset every %v", resetIntv) 82 | 83 | refreshIntv := util.MustParseDuration(s.config.RefreshInterval) 84 | refreshTimer := time.NewTimer(refreshIntv) 85 | log.Printf("Set policy state refresh every %v", refreshIntv) 86 | 87 | go func() { 88 | for { 89 | select { 90 | case <-resetTimer.C: 91 | s.resetStats() 92 | resetTimer.Reset(resetIntv) 93 | case <-refreshTimer.C: 94 | s.refreshState() 95 | refreshTimer.Reset(refreshIntv) 96 | } 97 | } 98 | }() 99 | 100 | for i := 0; i < s.config.Workers; i++ { 101 | s.startPolicyWorker() 102 | } 103 | log.Printf("Running with %v policy workers", s.config.Workers) 104 | return s 105 | } 106 | 107 | func (s *PolicyServer) startPolicyWorker() { 108 | go func() { 109 | for { 110 | select { 111 | case ip := <-s.banChannel: 112 | s.doBan(ip) 113 | } 114 | } 115 | }() 116 | } 117 | 118 | func (s *PolicyServer) resetStats() { 119 | now := util.MakeTimestamp() 120 | banningTimeout := s.config.Banning.Timeout * 1000 121 | total := 0 122 | s.statsMu.Lock() 123 | defer s.statsMu.Unlock() 124 | 125 | for key, m := range s.stats { 126 | lastBeat := atomic.LoadInt64(&m.LastBeat) 127 | bannedAt := atomic.LoadInt64(&m.BannedAt) 128 | 129 | if now-bannedAt >= banningTimeout { 130 | atomic.StoreInt64(&m.BannedAt, 0) 131 | if atomic.CompareAndSwapInt32(&m.Banned, 1, 0) { 132 | log.Printf("Ban dropped for %v", key) 133 | delete(s.stats, key) 134 | total++ 135 | } 136 | } 137 | if now-lastBeat >= s.timeout { 138 | delete(s.stats, key) 139 | total++ 140 | } 141 | } 142 | log.Printf("Flushed stats for %v IP addresses", total) 143 | } 144 | 145 | func (s *PolicyServer) refreshState() { 146 | s.Lock() 147 | defer s.Unlock() 148 | var err error 149 | 150 | s.blacklist, err = s.storage.GetBlacklist() 151 | if err != nil { 152 | log.Printf("Failed to get blacklist from backend: %v", err) 153 | } 154 | s.whitelist, err = s.storage.GetWhitelist() 155 | if err != nil { 156 | log.Printf("Failed to get whitelist from backend: %v", err) 157 | } 158 | log.Println("Policy state refresh complete") 159 | } 160 | 161 | func (s *PolicyServer) NewStats() *Stats { 162 | x := &Stats{ 163 | ConnLimit: s.config.Limits.Limit, 164 | } 165 | x.heartbeat() 166 | return x 167 | } 168 | 169 | func (s *PolicyServer) Get(ip string) *Stats { 170 | s.statsMu.Lock() 171 | defer s.statsMu.Unlock() 172 | 173 | if x, ok := s.stats[ip]; !ok { 174 | x = s.NewStats() 175 | s.stats[ip] = x 176 | return x 177 | } else { 178 | x.heartbeat() 179 | return x 180 | } 181 | } 182 | 183 | func (s *PolicyServer) BanClient(ip string) { 184 | x := s.Get(ip) 185 | s.forceBan(x, ip) 186 | } 187 | 188 | func (s *PolicyServer) IsBanned(ip string) bool { 189 | x := s.Get(ip) 190 | return atomic.LoadInt32(&x.Banned) > 0 191 | } 192 | 193 | func (s *PolicyServer) ApplyLimitPolicy(ip string) bool { 194 | if !s.config.Limits.Enabled { 195 | return true 196 | } 197 | now := util.MakeTimestamp() 198 | if now-s.startedAt > s.grace { 199 | return s.Get(ip).decrLimit() > 0 200 | } 201 | return true 202 | } 203 | 204 | func (s *PolicyServer) ApplyLoginPolicy(addy, ip string) bool { 205 | if s.InBlackList(addy) { 206 | x := s.Get(ip) 207 | s.forceBan(x, ip) 208 | return false 209 | } 210 | return true 211 | } 212 | 213 | func (s *PolicyServer) ApplyMalformedPolicy(ip string) bool { 214 | x := s.Get(ip) 215 | n := x.incrMalformed() 216 | if n >= s.config.Banning.MalformedLimit { 217 | s.forceBan(x, ip) 218 | return false 219 | } 220 | return true 221 | } 222 | 223 | func (s *PolicyServer) ApplySharePolicy(ip string, validShare bool) bool { 224 | x := s.Get(ip) 225 | x.Lock() 226 | 227 | if validShare { 228 | x.ValidShares++ 229 | if s.config.Limits.Enabled { 230 | x.incrLimit(s.config.Limits.LimitJump) 231 | } 232 | } else { 233 | x.InvalidShares++ 234 | } 235 | 236 | totalShares := x.ValidShares + x.InvalidShares 237 | if totalShares < s.config.Banning.CheckThreshold { 238 | x.Unlock() 239 | return true 240 | } 241 | validShares := float32(x.ValidShares) 242 | invalidShares := float32(x.InvalidShares) 243 | x.resetShares() 244 | x.Unlock() 245 | 246 | ratio := invalidShares / validShares 247 | 248 | if ratio >= s.config.Banning.InvalidPercent/100.0 { 249 | s.forceBan(x, ip) 250 | return false 251 | } 252 | return true 253 | } 254 | 255 | func (x *Stats) resetShares() { 256 | x.ValidShares = 0 257 | x.InvalidShares = 0 258 | } 259 | 260 | func (s *PolicyServer) forceBan(x *Stats, ip string) { 261 | if !s.config.Banning.Enabled || s.InWhiteList(ip) { 262 | return 263 | } 264 | atomic.StoreInt64(&x.BannedAt, util.MakeTimestamp()) 265 | 266 | if atomic.CompareAndSwapInt32(&x.Banned, 0, 1) { 267 | if len(s.config.Banning.IPSet) > 0 { 268 | s.banChannel <- ip 269 | } else { 270 | log.Println("Banned peer", ip) 271 | } 272 | } 273 | } 274 | 275 | func (x *Stats) incrLimit(n int32) { 276 | atomic.AddInt32(&x.ConnLimit, n) 277 | } 278 | 279 | func (x *Stats) incrMalformed() int32 { 280 | return atomic.AddInt32(&x.Malformed, 1) 281 | } 282 | 283 | func (x *Stats) decrLimit() int32 { 284 | return atomic.AddInt32(&x.ConnLimit, -1) 285 | } 286 | 287 | func (s *PolicyServer) InBlackList(addy string) bool { 288 | s.RLock() 289 | defer s.RUnlock() 290 | return util.StringInSlice(addy, s.blacklist) 291 | } 292 | 293 | func (s *PolicyServer) InWhiteList(ip string) bool { 294 | s.RLock() 295 | defer s.RUnlock() 296 | return util.StringInSlice(ip, s.whitelist) 297 | } 298 | 299 | func (s *PolicyServer) doBan(ip string) { 300 | set, timeout := s.config.Banning.IPSet, s.config.Banning.Timeout 301 | cmd := fmt.Sprintf("sudo ipset add %s %s timeout %v -!", set, ip, timeout) 302 | args := strings.Fields(cmd) 303 | head := args[0] 304 | args = args[1:] 305 | 306 | log.Printf("Banned %v with timeout %v on ipset %s", ip, timeout, set) 307 | 308 | _, err := exec.Command(head, args...).Output() 309 | if err != nil { 310 | log.Printf("CMD Error: %s", err) 311 | } 312 | } 313 | 314 | func (x *Stats) heartbeat() { 315 | now := util.MakeTimestamp() 316 | atomic.StoreInt64(&x.LastBeat, now) 317 | } 318 | -------------------------------------------------------------------------------- /proxy/blocks.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "log" 5 | "math/big" 6 | "strconv" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/ethereum/go-ethereum/common" 11 | 12 | "github.com/sammy007/open-ethereum-pool/rpc" 13 | "github.com/sammy007/open-ethereum-pool/util" 14 | ) 15 | 16 | const maxBacklog = 3 17 | 18 | type heightDiffPair struct { 19 | diff *big.Int 20 | height uint64 21 | } 22 | 23 | type BlockTemplate struct { 24 | sync.RWMutex 25 | Header string 26 | Seed string 27 | Target string 28 | Difficulty *big.Int 29 | Height uint64 30 | GetPendingBlockCache *rpc.GetBlockReplyPart 31 | nonces map[string]bool 32 | headers map[string]heightDiffPair 33 | } 34 | 35 | type Block struct { 36 | difficulty *big.Int 37 | hashNoNonce common.Hash 38 | nonce uint64 39 | mixDigest common.Hash 40 | number uint64 41 | } 42 | 43 | func (b Block) Difficulty() *big.Int { return b.difficulty } 44 | func (b Block) HashNoNonce() common.Hash { return b.hashNoNonce } 45 | func (b Block) Nonce() uint64 { return b.nonce } 46 | func (b Block) MixDigest() common.Hash { return b.mixDigest } 47 | func (b Block) NumberU64() uint64 { return b.number } 48 | 49 | func (s *ProxyServer) fetchBlockTemplate() { 50 | rpc := s.rpc() 51 | t := s.currentBlockTemplate() 52 | pendingReply, height, diff, err := s.fetchPendingBlock() 53 | if err != nil { 54 | log.Printf("Error while refreshing pending block on %s: %s", rpc.Name, err) 55 | return 56 | } 57 | reply, err := rpc.GetWork() 58 | if err != nil { 59 | log.Printf("Error while refreshing block template on %s: %s", rpc.Name, err) 60 | return 61 | } 62 | // No need to update, we have fresh job 63 | if t != nil && t.Header == reply[0] { 64 | return 65 | } 66 | 67 | pendingReply.Difficulty = util.ToHex(s.config.Proxy.Difficulty) 68 | 69 | newTemplate := BlockTemplate{ 70 | Header: reply[0], 71 | Seed: reply[1], 72 | Target: reply[2], 73 | Height: height, 74 | Difficulty: big.NewInt(diff), 75 | GetPendingBlockCache: pendingReply, 76 | headers: make(map[string]heightDiffPair), 77 | } 78 | // Copy job backlog and add current one 79 | newTemplate.headers[reply[0]] = heightDiffPair{ 80 | diff: util.TargetHexToDiff(reply[2]), 81 | height: height, 82 | } 83 | if t != nil { 84 | for k, v := range t.headers { 85 | if v.height > height-maxBacklog { 86 | newTemplate.headers[k] = v 87 | } 88 | } 89 | } 90 | s.blockTemplate.Store(&newTemplate) 91 | log.Printf("New block to mine on %s at height %d / %s", rpc.Name, height, reply[0][0:10]) 92 | 93 | // Stratum 94 | if s.config.Proxy.Stratum.Enabled { 95 | go s.broadcastNewJobs() 96 | } 97 | } 98 | 99 | func (s *ProxyServer) fetchPendingBlock() (*rpc.GetBlockReplyPart, uint64, int64, error) { 100 | rpc := s.rpc() 101 | reply, err := rpc.GetPendingBlock() 102 | if err != nil { 103 | log.Printf("Error while refreshing pending block on %s: %s", rpc.Name, err) 104 | return nil, 0, 0, err 105 | } 106 | blockNumber, err := strconv.ParseUint(strings.Replace(reply.Number, "0x", "", -1), 16, 64) 107 | if err != nil { 108 | log.Println("Can't parse pending block number") 109 | return nil, 0, 0, err 110 | } 111 | blockDiff, err := strconv.ParseInt(strings.Replace(reply.Difficulty, "0x", "", -1), 16, 64) 112 | if err != nil { 113 | log.Println("Can't parse pending block difficulty") 114 | return nil, 0, 0, err 115 | } 116 | return reply, blockNumber, blockDiff, nil 117 | } 118 | -------------------------------------------------------------------------------- /proxy/config.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "github.com/sammy007/open-ethereum-pool/api" 5 | "github.com/sammy007/open-ethereum-pool/payouts" 6 | "github.com/sammy007/open-ethereum-pool/policy" 7 | "github.com/sammy007/open-ethereum-pool/storage" 8 | ) 9 | 10 | type Config struct { 11 | Name string `json:"name"` 12 | Proxy Proxy `json:"proxy"` 13 | Api api.ApiConfig `json:"api"` 14 | Upstream []Upstream `json:"upstream"` 15 | UpstreamCheckInterval string `json:"upstreamCheckInterval"` 16 | 17 | Threads int `json:"threads"` 18 | 19 | Coin string `json:"coin"` 20 | Redis storage.Config `json:"redis"` 21 | 22 | BlockUnlocker payouts.UnlockerConfig `json:"unlocker"` 23 | Payouts payouts.PayoutsConfig `json:"payouts"` 24 | 25 | NewrelicName string `json:"newrelicName"` 26 | NewrelicKey string `json:"newrelicKey"` 27 | NewrelicVerbose bool `json:"newrelicVerbose"` 28 | NewrelicEnabled bool `json:"newrelicEnabled"` 29 | } 30 | 31 | type Proxy struct { 32 | Enabled bool `json:"enabled"` 33 | Listen string `json:"listen"` 34 | LimitHeadersSize int `json:"limitHeadersSize"` 35 | LimitBodySize int64 `json:"limitBodySize"` 36 | BehindReverseProxy bool `json:"behindReverseProxy"` 37 | BlockRefreshInterval string `json:"blockRefreshInterval"` 38 | Difficulty int64 `json:"difficulty"` 39 | StateUpdateInterval string `json:"stateUpdateInterval"` 40 | HashrateExpiration string `json:"hashrateExpiration"` 41 | 42 | Policy policy.Config `json:"policy"` 43 | 44 | MaxFails int64 `json:"maxFails"` 45 | HealthCheck bool `json:"healthCheck"` 46 | 47 | Stratum Stratum `json:"stratum"` 48 | } 49 | 50 | type Stratum struct { 51 | Enabled bool `json:"enabled"` 52 | Listen string `json:"listen"` 53 | Timeout string `json:"timeout"` 54 | MaxConn int `json:"maxConn"` 55 | } 56 | 57 | type Upstream struct { 58 | Name string `json:"name"` 59 | Url string `json:"url"` 60 | Timeout string `json:"timeout"` 61 | } 62 | -------------------------------------------------------------------------------- /proxy/handlers.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "log" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/sammy007/open-ethereum-pool/rpc" 9 | "github.com/sammy007/open-ethereum-pool/util" 10 | ) 11 | 12 | // Allow only lowercase hexadecimal with 0x prefix 13 | var noncePattern = regexp.MustCompile("^0x[0-9a-f]{16}$") 14 | var hashPattern = regexp.MustCompile("^0x[0-9a-f]{64}$") 15 | var workerPattern = regexp.MustCompile("^[0-9a-zA-Z-_]{1,8}$") 16 | 17 | // Stratum 18 | func (s *ProxyServer) handleLoginRPC(cs *Session, params []string, id string) (bool, *ErrorReply) { 19 | if len(params) == 0 { 20 | return false, &ErrorReply{Code: -1, Message: "Invalid params"} 21 | } 22 | 23 | login := strings.ToLower(params[0]) 24 | if !util.IsValidHexAddress(login) { 25 | return false, &ErrorReply{Code: -1, Message: "Invalid login"} 26 | } 27 | if !s.policy.ApplyLoginPolicy(login, cs.ip) { 28 | return false, &ErrorReply{Code: -1, Message: "You are blacklisted"} 29 | } 30 | cs.login = login 31 | s.registerSession(cs) 32 | log.Printf("Stratum miner connected %v@%v", login, cs.ip) 33 | return true, nil 34 | } 35 | 36 | func (s *ProxyServer) handleGetWorkRPC(cs *Session) ([]string, *ErrorReply) { 37 | t := s.currentBlockTemplate() 38 | if t == nil || len(t.Header) == 0 || s.isSick() { 39 | return nil, &ErrorReply{Code: 0, Message: "Work not ready"} 40 | } 41 | return []string{t.Header, t.Seed, s.diff}, nil 42 | } 43 | 44 | // Stratum 45 | func (s *ProxyServer) handleTCPSubmitRPC(cs *Session, id string, params []string) (bool, *ErrorReply) { 46 | s.sessionsMu.RLock() 47 | _, ok := s.sessions[cs] 48 | s.sessionsMu.RUnlock() 49 | 50 | if !ok { 51 | return false, &ErrorReply{Code: 25, Message: "Not subscribed"} 52 | } 53 | return s.handleSubmitRPC(cs, cs.login, id, params) 54 | } 55 | 56 | func (s *ProxyServer) handleSubmitRPC(cs *Session, login, id string, params []string) (bool, *ErrorReply) { 57 | if !workerPattern.MatchString(id) { 58 | id = "0" 59 | } 60 | if len(params) != 3 { 61 | s.policy.ApplyMalformedPolicy(cs.ip) 62 | log.Printf("Malformed params from %s@%s %v", login, cs.ip, params) 63 | return false, &ErrorReply{Code: -1, Message: "Invalid params"} 64 | } 65 | 66 | if !noncePattern.MatchString(params[0]) || !hashPattern.MatchString(params[1]) || !hashPattern.MatchString(params[2]) { 67 | s.policy.ApplyMalformedPolicy(cs.ip) 68 | log.Printf("Malformed PoW result from %s@%s %v", login, cs.ip, params) 69 | return false, &ErrorReply{Code: -1, Message: "Malformed PoW result"} 70 | } 71 | t := s.currentBlockTemplate() 72 | exist, validShare := s.processShare(login, id, cs.ip, t, params) 73 | ok := s.policy.ApplySharePolicy(cs.ip, !exist && validShare) 74 | 75 | if exist { 76 | log.Printf("Duplicate share from %s@%s %v", login, cs.ip, params) 77 | return false, &ErrorReply{Code: 22, Message: "Duplicate share"} 78 | } 79 | 80 | if !validShare { 81 | log.Printf("Invalid share from %s@%s", login, cs.ip) 82 | // Bad shares limit reached, return error and close 83 | if !ok { 84 | return false, &ErrorReply{Code: 23, Message: "Invalid share"} 85 | } 86 | return false, nil 87 | } 88 | log.Printf("Valid share from %s@%s", login, cs.ip) 89 | 90 | if !ok { 91 | return true, &ErrorReply{Code: -1, Message: "High rate of invalid shares"} 92 | } 93 | return true, nil 94 | } 95 | 96 | func (s *ProxyServer) handleGetBlockByNumberRPC() *rpc.GetBlockReplyPart { 97 | t := s.currentBlockTemplate() 98 | var reply *rpc.GetBlockReplyPart 99 | if t != nil { 100 | reply = t.GetPendingBlockCache 101 | } 102 | return reply 103 | } 104 | 105 | func (s *ProxyServer) handleUnknownRPC(cs *Session, m string) *ErrorReply { 106 | log.Printf("Unknown request method %s from %s", m, cs.ip) 107 | s.policy.ApplyMalformedPolicy(cs.ip) 108 | return &ErrorReply{Code: -3, Message: "Method not found"} 109 | } 110 | -------------------------------------------------------------------------------- /proxy/miner.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "log" 5 | "math/big" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/ethereum/ethash" 10 | "github.com/ethereum/go-ethereum/common" 11 | ) 12 | 13 | var hasher = ethash.New() 14 | 15 | func (s *ProxyServer) processShare(login, id, ip string, t *BlockTemplate, params []string) (bool, bool) { 16 | nonceHex := params[0] 17 | hashNoNonce := params[1] 18 | mixDigest := params[2] 19 | nonce, _ := strconv.ParseUint(strings.Replace(nonceHex, "0x", "", -1), 16, 64) 20 | shareDiff := s.config.Proxy.Difficulty 21 | 22 | h, ok := t.headers[hashNoNonce] 23 | if !ok { 24 | log.Printf("Stale share from %v@%v", login, ip) 25 | return false, false 26 | } 27 | 28 | share := Block{ 29 | number: h.height, 30 | hashNoNonce: common.HexToHash(hashNoNonce), 31 | difficulty: big.NewInt(shareDiff), 32 | nonce: nonce, 33 | mixDigest: common.HexToHash(mixDigest), 34 | } 35 | 36 | block := Block{ 37 | number: h.height, 38 | hashNoNonce: common.HexToHash(hashNoNonce), 39 | difficulty: h.diff, 40 | nonce: nonce, 41 | mixDigest: common.HexToHash(mixDigest), 42 | } 43 | 44 | if !hasher.Verify(share) { 45 | return false, false 46 | } 47 | 48 | if hasher.Verify(block) { 49 | ok, err := s.rpc().SubmitBlock(params) 50 | if err != nil { 51 | log.Printf("Block submission failure at height %v for %v: %v", h.height, t.Header, err) 52 | } else if !ok { 53 | log.Printf("Block rejected at height %v for %v", h.height, t.Header) 54 | return false, false 55 | } else { 56 | s.fetchBlockTemplate() 57 | exist, err := s.backend.WriteBlock(login, id, params, shareDiff, h.diff.Int64(), h.height, s.hashrateExpiration) 58 | if exist { 59 | return true, false 60 | } 61 | if err != nil { 62 | log.Println("Failed to insert block candidate into backend:", err) 63 | } else { 64 | log.Printf("Inserted block %v to backend", h.height) 65 | } 66 | log.Printf("Block found by miner %v@%v at height %d", login, ip, h.height) 67 | } 68 | } else { 69 | exist, err := s.backend.WriteShare(login, id, params, shareDiff, h.height, s.hashrateExpiration) 70 | if exist { 71 | return true, false 72 | } 73 | if err != nil { 74 | log.Println("Failed to insert share data into backend:", err) 75 | } 76 | } 77 | return false, true 78 | } 79 | -------------------------------------------------------------------------------- /proxy/proto.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import "encoding/json" 4 | 5 | type JSONRpcReq struct { 6 | Id json.RawMessage `json:"id"` 7 | Method string `json:"method"` 8 | Params json.RawMessage `json:"params"` 9 | } 10 | 11 | type StratumReq struct { 12 | JSONRpcReq 13 | Worker string `json:"worker"` 14 | } 15 | 16 | // Stratum 17 | type JSONPushMessage struct { 18 | // FIXME: Temporarily add ID for Claymore compliance 19 | Id int64 `json:"id"` 20 | Version string `json:"jsonrpc"` 21 | Result interface{} `json:"result"` 22 | } 23 | 24 | type JSONRpcResp struct { 25 | Id json.RawMessage `json:"id"` 26 | Version string `json:"jsonrpc"` 27 | Result interface{} `json:"result"` 28 | Error interface{} `json:"error,omitempty"` 29 | } 30 | 31 | type SubmitReply struct { 32 | Status string `json:"status"` 33 | } 34 | 35 | type ErrorReply struct { 36 | Code int `json:"code"` 37 | Message string `json:"message"` 38 | } 39 | -------------------------------------------------------------------------------- /proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "log" 7 | "net" 8 | "net/http" 9 | "strings" 10 | "sync" 11 | "sync/atomic" 12 | "time" 13 | 14 | "github.com/gorilla/mux" 15 | 16 | "github.com/sammy007/open-ethereum-pool/policy" 17 | "github.com/sammy007/open-ethereum-pool/rpc" 18 | "github.com/sammy007/open-ethereum-pool/storage" 19 | "github.com/sammy007/open-ethereum-pool/util" 20 | ) 21 | 22 | type ProxyServer struct { 23 | config *Config 24 | blockTemplate atomic.Value 25 | upstream int32 26 | upstreams []*rpc.RPCClient 27 | backend *storage.RedisClient 28 | diff string 29 | policy *policy.PolicyServer 30 | hashrateExpiration time.Duration 31 | failsCount int64 32 | 33 | // Stratum 34 | sessionsMu sync.RWMutex 35 | sessions map[*Session]struct{} 36 | timeout time.Duration 37 | } 38 | 39 | type Session struct { 40 | ip string 41 | enc *json.Encoder 42 | 43 | // Stratum 44 | sync.Mutex 45 | conn *net.TCPConn 46 | login string 47 | } 48 | 49 | func NewProxy(cfg *Config, backend *storage.RedisClient) *ProxyServer { 50 | if len(cfg.Name) == 0 { 51 | log.Fatal("You must set instance name") 52 | } 53 | policy := policy.Start(&cfg.Proxy.Policy, backend) 54 | 55 | proxy := &ProxyServer{config: cfg, backend: backend, policy: policy} 56 | proxy.diff = util.GetTargetHex(cfg.Proxy.Difficulty) 57 | 58 | proxy.upstreams = make([]*rpc.RPCClient, len(cfg.Upstream)) 59 | for i, v := range cfg.Upstream { 60 | proxy.upstreams[i] = rpc.NewRPCClient(v.Name, v.Url, v.Timeout) 61 | log.Printf("Upstream: %s => %s", v.Name, v.Url) 62 | } 63 | log.Printf("Default upstream: %s => %s", proxy.rpc().Name, proxy.rpc().Url) 64 | 65 | if cfg.Proxy.Stratum.Enabled { 66 | proxy.sessions = make(map[*Session]struct{}) 67 | go proxy.ListenTCP() 68 | } 69 | 70 | proxy.fetchBlockTemplate() 71 | 72 | proxy.hashrateExpiration = util.MustParseDuration(cfg.Proxy.HashrateExpiration) 73 | 74 | refreshIntv := util.MustParseDuration(cfg.Proxy.BlockRefreshInterval) 75 | refreshTimer := time.NewTimer(refreshIntv) 76 | log.Printf("Set block refresh every %v", refreshIntv) 77 | 78 | checkIntv := util.MustParseDuration(cfg.UpstreamCheckInterval) 79 | checkTimer := time.NewTimer(checkIntv) 80 | 81 | stateUpdateIntv := util.MustParseDuration(cfg.Proxy.StateUpdateInterval) 82 | stateUpdateTimer := time.NewTimer(stateUpdateIntv) 83 | 84 | go func() { 85 | for { 86 | select { 87 | case <-refreshTimer.C: 88 | proxy.fetchBlockTemplate() 89 | refreshTimer.Reset(refreshIntv) 90 | } 91 | } 92 | }() 93 | 94 | go func() { 95 | for { 96 | select { 97 | case <-checkTimer.C: 98 | proxy.checkUpstreams() 99 | checkTimer.Reset(checkIntv) 100 | } 101 | } 102 | }() 103 | 104 | go func() { 105 | for { 106 | select { 107 | case <-stateUpdateTimer.C: 108 | t := proxy.currentBlockTemplate() 109 | if t != nil { 110 | err := backend.WriteNodeState(cfg.Name, t.Height, t.Difficulty) 111 | if err != nil { 112 | log.Printf("Failed to write node state to backend: %v", err) 113 | proxy.markSick() 114 | } else { 115 | proxy.markOk() 116 | } 117 | } 118 | stateUpdateTimer.Reset(stateUpdateIntv) 119 | } 120 | } 121 | }() 122 | 123 | return proxy 124 | } 125 | 126 | func (s *ProxyServer) Start() { 127 | log.Printf("Starting proxy on %v", s.config.Proxy.Listen) 128 | r := mux.NewRouter() 129 | r.Handle("/{login:0x[0-9a-fA-F]{40}}/{id:[0-9a-zA-Z-_]{1,8}}", s) 130 | r.Handle("/{login:0x[0-9a-fA-F]{40}}", s) 131 | srv := &http.Server{ 132 | Addr: s.config.Proxy.Listen, 133 | Handler: r, 134 | MaxHeaderBytes: s.config.Proxy.LimitHeadersSize, 135 | } 136 | err := srv.ListenAndServe() 137 | if err != nil { 138 | log.Fatalf("Failed to start proxy: %v", err) 139 | } 140 | } 141 | 142 | func (s *ProxyServer) rpc() *rpc.RPCClient { 143 | i := atomic.LoadInt32(&s.upstream) 144 | return s.upstreams[i] 145 | } 146 | 147 | func (s *ProxyServer) checkUpstreams() { 148 | candidate := int32(0) 149 | backup := false 150 | 151 | for i, v := range s.upstreams { 152 | if v.Check() && !backup { 153 | candidate = int32(i) 154 | backup = true 155 | } 156 | } 157 | 158 | if s.upstream != candidate { 159 | log.Printf("Switching to %v upstream", s.upstreams[candidate].Name) 160 | atomic.StoreInt32(&s.upstream, candidate) 161 | } 162 | } 163 | 164 | func (s *ProxyServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 165 | if r.Method != "POST" { 166 | s.writeError(w, 405, "rpc: POST method required, received "+r.Method) 167 | return 168 | } 169 | ip := s.remoteAddr(r) 170 | if !s.policy.IsBanned(ip) { 171 | s.handleClient(w, r, ip) 172 | } 173 | } 174 | 175 | func (s *ProxyServer) remoteAddr(r *http.Request) string { 176 | if s.config.Proxy.BehindReverseProxy { 177 | ip := r.Header.Get("X-Forwarded-For") 178 | if len(ip) > 0 && net.ParseIP(ip) != nil { 179 | return ip 180 | } 181 | } 182 | ip, _, _ := net.SplitHostPort(r.RemoteAddr) 183 | return ip 184 | } 185 | 186 | func (s *ProxyServer) handleClient(w http.ResponseWriter, r *http.Request, ip string) { 187 | if r.ContentLength > s.config.Proxy.LimitBodySize { 188 | log.Printf("Socket flood from %s", ip) 189 | s.policy.ApplyMalformedPolicy(ip) 190 | http.Error(w, "Request too large", http.StatusExpectationFailed) 191 | return 192 | } 193 | r.Body = http.MaxBytesReader(w, r.Body, s.config.Proxy.LimitBodySize) 194 | defer r.Body.Close() 195 | 196 | cs := &Session{ip: ip, enc: json.NewEncoder(w)} 197 | dec := json.NewDecoder(r.Body) 198 | for { 199 | var req JSONRpcReq 200 | if err := dec.Decode(&req); err == io.EOF { 201 | break 202 | } else if err != nil { 203 | log.Printf("Malformed request from %v: %v", ip, err) 204 | s.policy.ApplyMalformedPolicy(ip) 205 | return 206 | } 207 | cs.handleMessage(s, r, &req) 208 | } 209 | } 210 | 211 | func (cs *Session) handleMessage(s *ProxyServer, r *http.Request, req *JSONRpcReq) { 212 | if req.Id == nil { 213 | log.Printf("Missing RPC id from %s", cs.ip) 214 | s.policy.ApplyMalformedPolicy(cs.ip) 215 | return 216 | } 217 | 218 | vars := mux.Vars(r) 219 | login := strings.ToLower(vars["login"]) 220 | 221 | if !util.IsValidHexAddress(login) { 222 | errReply := &ErrorReply{Code: -1, Message: "Invalid login"} 223 | cs.sendError(req.Id, errReply) 224 | return 225 | } 226 | if !s.policy.ApplyLoginPolicy(login, cs.ip) { 227 | errReply := &ErrorReply{Code: -1, Message: "You are blacklisted"} 228 | cs.sendError(req.Id, errReply) 229 | return 230 | } 231 | 232 | // Handle RPC methods 233 | switch req.Method { 234 | case "eth_getWork": 235 | reply, errReply := s.handleGetWorkRPC(cs) 236 | if errReply != nil { 237 | cs.sendError(req.Id, errReply) 238 | break 239 | } 240 | cs.sendResult(req.Id, &reply) 241 | case "eth_submitWork": 242 | if req.Params != nil { 243 | var params []string 244 | err := json.Unmarshal(req.Params, ¶ms) 245 | if err != nil { 246 | log.Printf("Unable to parse params from %v", cs.ip) 247 | s.policy.ApplyMalformedPolicy(cs.ip) 248 | break 249 | } 250 | reply, errReply := s.handleSubmitRPC(cs, login, vars["id"], params) 251 | if errReply != nil { 252 | cs.sendError(req.Id, errReply) 253 | break 254 | } 255 | cs.sendResult(req.Id, &reply) 256 | } else { 257 | s.policy.ApplyMalformedPolicy(cs.ip) 258 | errReply := &ErrorReply{Code: -1, Message: "Malformed request"} 259 | cs.sendError(req.Id, errReply) 260 | } 261 | case "eth_getBlockByNumber": 262 | reply := s.handleGetBlockByNumberRPC() 263 | cs.sendResult(req.Id, reply) 264 | case "eth_submitHashrate": 265 | cs.sendResult(req.Id, true) 266 | default: 267 | errReply := s.handleUnknownRPC(cs, req.Method) 268 | cs.sendError(req.Id, errReply) 269 | } 270 | } 271 | 272 | func (cs *Session) sendResult(id json.RawMessage, result interface{}) error { 273 | message := JSONRpcResp{Id: id, Version: "2.0", Error: nil, Result: result} 274 | return cs.enc.Encode(&message) 275 | } 276 | 277 | func (cs *Session) sendError(id json.RawMessage, reply *ErrorReply) error { 278 | message := JSONRpcResp{Id: id, Version: "2.0", Error: reply} 279 | return cs.enc.Encode(&message) 280 | } 281 | 282 | func (s *ProxyServer) writeError(w http.ResponseWriter, status int, msg string) { 283 | w.WriteHeader(status) 284 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 285 | } 286 | 287 | func (s *ProxyServer) currentBlockTemplate() *BlockTemplate { 288 | t := s.blockTemplate.Load() 289 | if t != nil { 290 | return t.(*BlockTemplate) 291 | } else { 292 | return nil 293 | } 294 | } 295 | 296 | func (s *ProxyServer) markSick() { 297 | atomic.AddInt64(&s.failsCount, 1) 298 | } 299 | 300 | func (s *ProxyServer) isSick() bool { 301 | x := atomic.LoadInt64(&s.failsCount) 302 | if s.config.Proxy.HealthCheck && x >= s.config.Proxy.MaxFails { 303 | return true 304 | } 305 | return false 306 | } 307 | 308 | func (s *ProxyServer) markOk() { 309 | atomic.StoreInt64(&s.failsCount, 0) 310 | } 311 | -------------------------------------------------------------------------------- /proxy/stratum.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "log" 9 | "net" 10 | "time" 11 | 12 | "github.com/sammy007/open-ethereum-pool/util" 13 | ) 14 | 15 | const ( 16 | MaxReqSize = 1024 17 | ) 18 | 19 | func (s *ProxyServer) ListenTCP() { 20 | timeout := util.MustParseDuration(s.config.Proxy.Stratum.Timeout) 21 | s.timeout = timeout 22 | 23 | addr, err := net.ResolveTCPAddr("tcp", s.config.Proxy.Stratum.Listen) 24 | if err != nil { 25 | log.Fatalf("Error: %v", err) 26 | } 27 | server, err := net.ListenTCP("tcp", addr) 28 | if err != nil { 29 | log.Fatalf("Error: %v", err) 30 | } 31 | defer server.Close() 32 | 33 | log.Printf("Stratum listening on %s", s.config.Proxy.Stratum.Listen) 34 | var accept = make(chan int, s.config.Proxy.Stratum.MaxConn) 35 | n := 0 36 | 37 | for { 38 | conn, err := server.AcceptTCP() 39 | if err != nil { 40 | continue 41 | } 42 | conn.SetKeepAlive(true) 43 | 44 | ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String()) 45 | 46 | if s.policy.IsBanned(ip) || !s.policy.ApplyLimitPolicy(ip) { 47 | conn.Close() 48 | continue 49 | } 50 | n += 1 51 | cs := &Session{conn: conn, ip: ip} 52 | 53 | accept <- n 54 | go func(cs *Session) { 55 | err = s.handleTCPClient(cs) 56 | if err != nil { 57 | s.removeSession(cs) 58 | conn.Close() 59 | } 60 | <-accept 61 | }(cs) 62 | } 63 | } 64 | 65 | func (s *ProxyServer) handleTCPClient(cs *Session) error { 66 | cs.enc = json.NewEncoder(cs.conn) 67 | connbuff := bufio.NewReaderSize(cs.conn, MaxReqSize) 68 | s.setDeadline(cs.conn) 69 | 70 | for { 71 | data, isPrefix, err := connbuff.ReadLine() 72 | if isPrefix { 73 | log.Printf("Socket flood detected from %s", cs.ip) 74 | s.policy.BanClient(cs.ip) 75 | return err 76 | } else if err == io.EOF { 77 | log.Printf("Client %s disconnected", cs.ip) 78 | s.removeSession(cs) 79 | break 80 | } else if err != nil { 81 | log.Printf("Error reading from socket: %v", err) 82 | return err 83 | } 84 | 85 | if len(data) > 1 { 86 | var req StratumReq 87 | err = json.Unmarshal(data, &req) 88 | if err != nil { 89 | s.policy.ApplyMalformedPolicy(cs.ip) 90 | log.Printf("Malformed stratum request from %s: %v", cs.ip, err) 91 | return err 92 | } 93 | s.setDeadline(cs.conn) 94 | err = cs.handleTCPMessage(s, &req) 95 | if err != nil { 96 | return err 97 | } 98 | } 99 | } 100 | return nil 101 | } 102 | 103 | func (cs *Session) handleTCPMessage(s *ProxyServer, req *StratumReq) error { 104 | // Handle RPC methods 105 | switch req.Method { 106 | case "eth_submitLogin": 107 | var params []string 108 | err := json.Unmarshal(req.Params, ¶ms) 109 | if err != nil { 110 | log.Println("Malformed stratum request params from", cs.ip) 111 | return err 112 | } 113 | reply, errReply := s.handleLoginRPC(cs, params, req.Worker) 114 | if errReply != nil { 115 | return cs.sendTCPError(req.Id, errReply) 116 | } 117 | return cs.sendTCPResult(req.Id, reply) 118 | case "eth_getWork": 119 | reply, errReply := s.handleGetWorkRPC(cs) 120 | if errReply != nil { 121 | return cs.sendTCPError(req.Id, errReply) 122 | } 123 | return cs.sendTCPResult(req.Id, &reply) 124 | case "eth_submitWork": 125 | var params []string 126 | err := json.Unmarshal(req.Params, ¶ms) 127 | if err != nil { 128 | log.Println("Malformed stratum request params from", cs.ip) 129 | return err 130 | } 131 | reply, errReply := s.handleTCPSubmitRPC(cs, req.Worker, params) 132 | if errReply != nil { 133 | return cs.sendTCPError(req.Id, errReply) 134 | } 135 | return cs.sendTCPResult(req.Id, &reply) 136 | case "eth_submitHashrate": 137 | return cs.sendTCPResult(req.Id, true) 138 | default: 139 | errReply := s.handleUnknownRPC(cs, req.Method) 140 | return cs.sendTCPError(req.Id, errReply) 141 | } 142 | } 143 | 144 | func (cs *Session) sendTCPResult(id json.RawMessage, result interface{}) error { 145 | cs.Lock() 146 | defer cs.Unlock() 147 | 148 | message := JSONRpcResp{Id: id, Version: "2.0", Error: nil, Result: result} 149 | return cs.enc.Encode(&message) 150 | } 151 | 152 | func (cs *Session) pushNewJob(result interface{}) error { 153 | cs.Lock() 154 | defer cs.Unlock() 155 | // FIXME: Temporarily add ID for Claymore compliance 156 | message := JSONPushMessage{Version: "2.0", Result: result, Id: 0} 157 | return cs.enc.Encode(&message) 158 | } 159 | 160 | func (cs *Session) sendTCPError(id json.RawMessage, reply *ErrorReply) error { 161 | cs.Lock() 162 | defer cs.Unlock() 163 | 164 | message := JSONRpcResp{Id: id, Version: "2.0", Error: reply} 165 | err := cs.enc.Encode(&message) 166 | if err != nil { 167 | return err 168 | } 169 | return errors.New(reply.Message) 170 | } 171 | 172 | func (self *ProxyServer) setDeadline(conn *net.TCPConn) { 173 | conn.SetDeadline(time.Now().Add(self.timeout)) 174 | } 175 | 176 | func (s *ProxyServer) registerSession(cs *Session) { 177 | s.sessionsMu.Lock() 178 | defer s.sessionsMu.Unlock() 179 | s.sessions[cs] = struct{}{} 180 | } 181 | 182 | func (s *ProxyServer) removeSession(cs *Session) { 183 | s.sessionsMu.Lock() 184 | defer s.sessionsMu.Unlock() 185 | delete(s.sessions, cs) 186 | } 187 | 188 | func (s *ProxyServer) broadcastNewJobs() { 189 | t := s.currentBlockTemplate() 190 | if t == nil || len(t.Header) == 0 || s.isSick() { 191 | return 192 | } 193 | reply := []string{t.Header, t.Seed, s.diff} 194 | 195 | s.sessionsMu.RLock() 196 | defer s.sessionsMu.RUnlock() 197 | 198 | count := len(s.sessions) 199 | log.Printf("Broadcasting new job to %v stratum miners", count) 200 | 201 | start := time.Now() 202 | bcast := make(chan int, 1024) 203 | n := 0 204 | 205 | for m, _ := range s.sessions { 206 | n++ 207 | bcast <- n 208 | 209 | go func(cs *Session) { 210 | err := cs.pushNewJob(&reply) 211 | <-bcast 212 | if err != nil { 213 | log.Printf("Job transmit error to %v@%v: %v", cs.login, cs.ip, err) 214 | s.removeSession(cs) 215 | } else { 216 | s.setDeadline(cs.conn) 217 | } 218 | }(m) 219 | } 220 | log.Printf("Jobs broadcast finished %s", time.Since(start)) 221 | } 222 | -------------------------------------------------------------------------------- /rpc/rpc.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "math/big" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | 15 | "github.com/ethereum/go-ethereum/common" 16 | 17 | "github.com/sammy007/open-ethereum-pool/util" 18 | ) 19 | 20 | type RPCClient struct { 21 | sync.RWMutex 22 | Url string 23 | Name string 24 | sick bool 25 | sickRate int 26 | successRate int 27 | client *http.Client 28 | } 29 | 30 | type GetBlockReply struct { 31 | Number string `json:"number"` 32 | Hash string `json:"hash"` 33 | Nonce string `json:"nonce"` 34 | Miner string `json:"miner"` 35 | Difficulty string `json:"difficulty"` 36 | GasLimit string `json:"gasLimit"` 37 | GasUsed string `json:"gasUsed"` 38 | Transactions []Tx `json:"transactions"` 39 | Uncles []string `json:"uncles"` 40 | // https://github.com/ethereum/EIPs/issues/95 41 | SealFields []string `json:"sealFields"` 42 | } 43 | 44 | type GetBlockReplyPart struct { 45 | Number string `json:"number"` 46 | Difficulty string `json:"difficulty"` 47 | } 48 | 49 | const receiptStatusSuccessful = "0x1" 50 | 51 | type TxReceipt struct { 52 | TxHash string `json:"transactionHash"` 53 | GasUsed string `json:"gasUsed"` 54 | BlockHash string `json:"blockHash"` 55 | Status string `json:"status"` 56 | } 57 | 58 | func (r *TxReceipt) Confirmed() bool { 59 | return len(r.BlockHash) > 0 60 | } 61 | 62 | // Use with previous method 63 | func (r *TxReceipt) Successful() bool { 64 | if len(r.Status) > 0 { 65 | return r.Status == receiptStatusSuccessful 66 | } 67 | return true 68 | } 69 | 70 | type Tx struct { 71 | Gas string `json:"gas"` 72 | GasPrice string `json:"gasPrice"` 73 | Hash string `json:"hash"` 74 | } 75 | 76 | type JSONRpcResp struct { 77 | Id *json.RawMessage `json:"id"` 78 | Result *json.RawMessage `json:"result"` 79 | Error map[string]interface{} `json:"error"` 80 | } 81 | 82 | func NewRPCClient(name, url, timeout string) *RPCClient { 83 | rpcClient := &RPCClient{Name: name, Url: url} 84 | timeoutIntv := util.MustParseDuration(timeout) 85 | rpcClient.client = &http.Client{ 86 | Timeout: timeoutIntv, 87 | } 88 | return rpcClient 89 | } 90 | 91 | func (r *RPCClient) GetWork() ([]string, error) { 92 | rpcResp, err := r.doPost(r.Url, "eth_getWork", []string{}) 93 | if err != nil { 94 | return nil, err 95 | } 96 | var reply []string 97 | err = json.Unmarshal(*rpcResp.Result, &reply) 98 | return reply, err 99 | } 100 | 101 | func (r *RPCClient) GetPendingBlock() (*GetBlockReplyPart, error) { 102 | rpcResp, err := r.doPost(r.Url, "eth_getBlockByNumber", []interface{}{"pending", false}) 103 | if err != nil { 104 | return nil, err 105 | } 106 | if rpcResp.Result != nil { 107 | var reply *GetBlockReplyPart 108 | err = json.Unmarshal(*rpcResp.Result, &reply) 109 | return reply, err 110 | } 111 | return nil, nil 112 | } 113 | 114 | func (r *RPCClient) GetBlockByHeight(height int64) (*GetBlockReply, error) { 115 | params := []interface{}{fmt.Sprintf("0x%x", height), true} 116 | return r.getBlockBy("eth_getBlockByNumber", params) 117 | } 118 | 119 | func (r *RPCClient) GetBlockByHash(hash string) (*GetBlockReply, error) { 120 | params := []interface{}{hash, true} 121 | return r.getBlockBy("eth_getBlockByHash", params) 122 | } 123 | 124 | func (r *RPCClient) GetUncleByBlockNumberAndIndex(height int64, index int) (*GetBlockReply, error) { 125 | params := []interface{}{fmt.Sprintf("0x%x", height), fmt.Sprintf("0x%x", index)} 126 | return r.getBlockBy("eth_getUncleByBlockNumberAndIndex", params) 127 | } 128 | 129 | func (r *RPCClient) getBlockBy(method string, params []interface{}) (*GetBlockReply, error) { 130 | rpcResp, err := r.doPost(r.Url, method, params) 131 | if err != nil { 132 | return nil, err 133 | } 134 | if rpcResp.Result != nil { 135 | var reply *GetBlockReply 136 | err = json.Unmarshal(*rpcResp.Result, &reply) 137 | return reply, err 138 | } 139 | return nil, nil 140 | } 141 | 142 | func (r *RPCClient) GetTxReceipt(hash string) (*TxReceipt, error) { 143 | rpcResp, err := r.doPost(r.Url, "eth_getTransactionReceipt", []string{hash}) 144 | if err != nil { 145 | return nil, err 146 | } 147 | if rpcResp.Result != nil { 148 | var reply *TxReceipt 149 | err = json.Unmarshal(*rpcResp.Result, &reply) 150 | return reply, err 151 | } 152 | return nil, nil 153 | } 154 | 155 | func (r *RPCClient) SubmitBlock(params []string) (bool, error) { 156 | rpcResp, err := r.doPost(r.Url, "eth_submitWork", params) 157 | if err != nil { 158 | return false, err 159 | } 160 | var reply bool 161 | err = json.Unmarshal(*rpcResp.Result, &reply) 162 | return reply, err 163 | } 164 | 165 | func (r *RPCClient) GetBalance(address string) (*big.Int, error) { 166 | rpcResp, err := r.doPost(r.Url, "eth_getBalance", []string{address, "latest"}) 167 | if err != nil { 168 | return nil, err 169 | } 170 | var reply string 171 | err = json.Unmarshal(*rpcResp.Result, &reply) 172 | if err != nil { 173 | return nil, err 174 | } 175 | return util.String2Big(reply), err 176 | } 177 | 178 | func (r *RPCClient) Sign(from string, s string) (string, error) { 179 | hash := sha256.Sum256([]byte(s)) 180 | rpcResp, err := r.doPost(r.Url, "eth_sign", []string{from, common.ToHex(hash[:])}) 181 | var reply string 182 | if err != nil { 183 | return reply, err 184 | } 185 | err = json.Unmarshal(*rpcResp.Result, &reply) 186 | if err != nil { 187 | return reply, err 188 | } 189 | if util.IsZeroHash(reply) { 190 | err = errors.New("Can't sign message, perhaps account is locked") 191 | } 192 | return reply, err 193 | } 194 | 195 | func (r *RPCClient) GetPeerCount() (int64, error) { 196 | rpcResp, err := r.doPost(r.Url, "net_peerCount", nil) 197 | if err != nil { 198 | return 0, err 199 | } 200 | var reply string 201 | err = json.Unmarshal(*rpcResp.Result, &reply) 202 | if err != nil { 203 | return 0, err 204 | } 205 | return strconv.ParseInt(strings.Replace(reply, "0x", "", -1), 16, 64) 206 | } 207 | 208 | func (r *RPCClient) SendTransaction(from, to, gas, gasPrice, value string, autoGas bool) (string, error) { 209 | params := map[string]string{ 210 | "from": from, 211 | "to": to, 212 | "value": value, 213 | } 214 | if !autoGas { 215 | params["gas"] = gas 216 | params["gasPrice"] = gasPrice 217 | } 218 | rpcResp, err := r.doPost(r.Url, "eth_sendTransaction", []interface{}{params}) 219 | var reply string 220 | if err != nil { 221 | return reply, err 222 | } 223 | err = json.Unmarshal(*rpcResp.Result, &reply) 224 | if err != nil { 225 | return reply, err 226 | } 227 | /* There is an inconsistence in a "standard". Geth returns error if it can't unlock signer account, 228 | * but Parity returns zero hash 0x000... if it can't send tx, so we must handle this case. 229 | * https://github.com/ethereum/wiki/wiki/JSON-RPC#returns-22 230 | */ 231 | if util.IsZeroHash(reply) { 232 | err = errors.New("transaction is not yet available") 233 | } 234 | return reply, err 235 | } 236 | 237 | func (r *RPCClient) doPost(url string, method string, params interface{}) (*JSONRpcResp, error) { 238 | jsonReq := map[string]interface{}{"jsonrpc": "2.0", "method": method, "params": params, "id": 0} 239 | data, _ := json.Marshal(jsonReq) 240 | 241 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) 242 | req.Header.Set("Content-Length", (string)(len(data))) 243 | req.Header.Set("Content-Type", "application/json") 244 | req.Header.Set("Accept", "application/json") 245 | 246 | resp, err := r.client.Do(req) 247 | if err != nil { 248 | r.markSick() 249 | return nil, err 250 | } 251 | defer resp.Body.Close() 252 | 253 | var rpcResp *JSONRpcResp 254 | err = json.NewDecoder(resp.Body).Decode(&rpcResp) 255 | if err != nil { 256 | r.markSick() 257 | return nil, err 258 | } 259 | if rpcResp.Error != nil { 260 | r.markSick() 261 | return nil, errors.New(rpcResp.Error["message"].(string)) 262 | } 263 | return rpcResp, err 264 | } 265 | 266 | func (r *RPCClient) Check() bool { 267 | _, err := r.GetWork() 268 | if err != nil { 269 | return false 270 | } 271 | r.markAlive() 272 | return !r.Sick() 273 | } 274 | 275 | func (r *RPCClient) Sick() bool { 276 | r.RLock() 277 | defer r.RUnlock() 278 | return r.sick 279 | } 280 | 281 | func (r *RPCClient) markSick() { 282 | r.Lock() 283 | r.sickRate++ 284 | r.successRate = 0 285 | if r.sickRate >= 5 { 286 | r.sick = true 287 | } 288 | r.Unlock() 289 | } 290 | 291 | func (r *RPCClient) markAlive() { 292 | r.Lock() 293 | r.successRate++ 294 | if r.successRate >= 5 { 295 | r.sick = false 296 | r.sickRate = 0 297 | r.successRate = 0 298 | } 299 | r.Unlock() 300 | } 301 | -------------------------------------------------------------------------------- /storage/redis.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | "math/big" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "gopkg.in/redis.v3" 11 | 12 | "github.com/sammy007/open-ethereum-pool/util" 13 | ) 14 | 15 | type Config struct { 16 | Endpoint string `json:"endpoint"` 17 | Password string `json:"password"` 18 | Database int64 `json:"database"` 19 | PoolSize int `json:"poolSize"` 20 | } 21 | 22 | type RedisClient struct { 23 | client *redis.Client 24 | prefix string 25 | } 26 | 27 | type BlockData struct { 28 | Height int64 `json:"height"` 29 | Timestamp int64 `json:"timestamp"` 30 | Difficulty int64 `json:"difficulty"` 31 | TotalShares int64 `json:"shares"` 32 | Uncle bool `json:"uncle"` 33 | UncleHeight int64 `json:"uncleHeight"` 34 | Orphan bool `json:"orphan"` 35 | Hash string `json:"hash"` 36 | Nonce string `json:"-"` 37 | PowHash string `json:"-"` 38 | MixDigest string `json:"-"` 39 | Reward *big.Int `json:"-"` 40 | ExtraReward *big.Int `json:"-"` 41 | ImmatureReward string `json:"-"` 42 | RewardString string `json:"reward"` 43 | RoundHeight int64 `json:"-"` 44 | candidateKey string 45 | immatureKey string 46 | } 47 | 48 | func (b *BlockData) RewardInShannon() int64 { 49 | reward := new(big.Int).Div(b.Reward, util.Shannon) 50 | return reward.Int64() 51 | } 52 | 53 | func (b *BlockData) serializeHash() string { 54 | if len(b.Hash) > 0 { 55 | return b.Hash 56 | } else { 57 | return "0x0" 58 | } 59 | } 60 | 61 | func (b *BlockData) RoundKey() string { 62 | return join(b.RoundHeight, b.Hash) 63 | } 64 | 65 | func (b *BlockData) key() string { 66 | return join(b.UncleHeight, b.Orphan, b.Nonce, b.serializeHash(), b.Timestamp, b.Difficulty, b.TotalShares, b.Reward) 67 | } 68 | 69 | type Miner struct { 70 | LastBeat int64 `json:"lastBeat"` 71 | HR int64 `json:"hr"` 72 | Offline bool `json:"offline"` 73 | startedAt int64 74 | } 75 | 76 | type Worker struct { 77 | Miner 78 | TotalHR int64 `json:"hr2"` 79 | } 80 | 81 | func NewRedisClient(cfg *Config, prefix string) *RedisClient { 82 | client := redis.NewClient(&redis.Options{ 83 | Addr: cfg.Endpoint, 84 | Password: cfg.Password, 85 | DB: cfg.Database, 86 | PoolSize: cfg.PoolSize, 87 | }) 88 | return &RedisClient{client: client, prefix: prefix} 89 | } 90 | 91 | func (r *RedisClient) Client() *redis.Client { 92 | return r.client 93 | } 94 | 95 | func (r *RedisClient) Check() (string, error) { 96 | return r.client.Ping().Result() 97 | } 98 | 99 | func (r *RedisClient) BgSave() (string, error) { 100 | return r.client.BgSave().Result() 101 | } 102 | 103 | // Always returns list of addresses. If Redis fails it will return empty list. 104 | func (r *RedisClient) GetBlacklist() ([]string, error) { 105 | cmd := r.client.SMembers(r.formatKey("blacklist")) 106 | if cmd.Err() != nil { 107 | return []string{}, cmd.Err() 108 | } 109 | return cmd.Val(), nil 110 | } 111 | 112 | // Always returns list of IPs. If Redis fails it will return empty list. 113 | func (r *RedisClient) GetWhitelist() ([]string, error) { 114 | cmd := r.client.SMembers(r.formatKey("whitelist")) 115 | if cmd.Err() != nil { 116 | return []string{}, cmd.Err() 117 | } 118 | return cmd.Val(), nil 119 | } 120 | 121 | func (r *RedisClient) WriteNodeState(id string, height uint64, diff *big.Int) error { 122 | tx := r.client.Multi() 123 | defer tx.Close() 124 | 125 | now := util.MakeTimestamp() / 1000 126 | 127 | _, err := tx.Exec(func() error { 128 | tx.HSet(r.formatKey("nodes"), join(id, "name"), id) 129 | tx.HSet(r.formatKey("nodes"), join(id, "height"), strconv.FormatUint(height, 10)) 130 | tx.HSet(r.formatKey("nodes"), join(id, "difficulty"), diff.String()) 131 | tx.HSet(r.formatKey("nodes"), join(id, "lastBeat"), strconv.FormatInt(now, 10)) 132 | return nil 133 | }) 134 | return err 135 | } 136 | 137 | func (r *RedisClient) GetNodeStates() ([]map[string]interface{}, error) { 138 | cmd := r.client.HGetAllMap(r.formatKey("nodes")) 139 | if cmd.Err() != nil { 140 | return nil, cmd.Err() 141 | } 142 | m := make(map[string]map[string]interface{}) 143 | for key, value := range cmd.Val() { 144 | parts := strings.Split(key, ":") 145 | if val, ok := m[parts[0]]; ok { 146 | val[parts[1]] = value 147 | } else { 148 | node := make(map[string]interface{}) 149 | node[parts[1]] = value 150 | m[parts[0]] = node 151 | } 152 | } 153 | v := make([]map[string]interface{}, len(m), len(m)) 154 | i := 0 155 | for _, value := range m { 156 | v[i] = value 157 | i++ 158 | } 159 | return v, nil 160 | } 161 | 162 | func (r *RedisClient) checkPoWExist(height uint64, params []string) (bool, error) { 163 | // Sweep PoW backlog for previous blocks, we have 3 templates back in RAM 164 | r.client.ZRemRangeByScore(r.formatKey("pow"), "-inf", fmt.Sprint("(", height-8)) 165 | val, err := r.client.ZAdd(r.formatKey("pow"), redis.Z{Score: float64(height), Member: strings.Join(params, ":")}).Result() 166 | return val == 0, err 167 | } 168 | 169 | func (r *RedisClient) WriteShare(login, id string, params []string, diff int64, height uint64, window time.Duration) (bool, error) { 170 | exist, err := r.checkPoWExist(height, params) 171 | if err != nil { 172 | return false, err 173 | } 174 | // Duplicate share, (nonce, powHash, mixDigest) pair exist 175 | if exist { 176 | return true, nil 177 | } 178 | tx := r.client.Multi() 179 | defer tx.Close() 180 | 181 | ms := util.MakeTimestamp() 182 | ts := ms / 1000 183 | 184 | _, err = tx.Exec(func() error { 185 | r.writeShare(tx, ms, ts, login, id, diff, window) 186 | tx.HIncrBy(r.formatKey("stats"), "roundShares", diff) 187 | return nil 188 | }) 189 | return false, err 190 | } 191 | 192 | func (r *RedisClient) WriteBlock(login, id string, params []string, diff, roundDiff int64, height uint64, window time.Duration) (bool, error) { 193 | exist, err := r.checkPoWExist(height, params) 194 | if err != nil { 195 | return false, err 196 | } 197 | // Duplicate share, (nonce, powHash, mixDigest) pair exist 198 | if exist { 199 | return true, nil 200 | } 201 | tx := r.client.Multi() 202 | defer tx.Close() 203 | 204 | ms := util.MakeTimestamp() 205 | ts := ms / 1000 206 | 207 | cmds, err := tx.Exec(func() error { 208 | r.writeShare(tx, ms, ts, login, id, diff, window) 209 | tx.HSet(r.formatKey("stats"), "lastBlockFound", strconv.FormatInt(ts, 10)) 210 | tx.HDel(r.formatKey("stats"), "roundShares") 211 | tx.ZIncrBy(r.formatKey("finders"), 1, login) 212 | tx.HIncrBy(r.formatKey("miners", login), "blocksFound", 1) 213 | tx.Rename(r.formatKey("shares", "roundCurrent"), r.formatRound(int64(height), params[0])) 214 | tx.HGetAllMap(r.formatRound(int64(height), params[0])) 215 | return nil 216 | }) 217 | if err != nil { 218 | return false, err 219 | } else { 220 | sharesMap, _ := cmds[10].(*redis.StringStringMapCmd).Result() 221 | totalShares := int64(0) 222 | for _, v := range sharesMap { 223 | n, _ := strconv.ParseInt(v, 10, 64) 224 | totalShares += n 225 | } 226 | hashHex := strings.Join(params, ":") 227 | s := join(hashHex, ts, roundDiff, totalShares) 228 | cmd := r.client.ZAdd(r.formatKey("blocks", "candidates"), redis.Z{Score: float64(height), Member: s}) 229 | return false, cmd.Err() 230 | } 231 | } 232 | 233 | func (r *RedisClient) writeShare(tx *redis.Multi, ms, ts int64, login, id string, diff int64, expire time.Duration) { 234 | tx.HIncrBy(r.formatKey("shares", "roundCurrent"), login, diff) 235 | tx.ZAdd(r.formatKey("hashrate"), redis.Z{Score: float64(ts), Member: join(diff, login, id, ms)}) 236 | tx.ZAdd(r.formatKey("hashrate", login), redis.Z{Score: float64(ts), Member: join(diff, id, ms)}) 237 | tx.Expire(r.formatKey("hashrate", login), expire) // Will delete hashrates for miners that gone 238 | tx.HSet(r.formatKey("miners", login), "lastShare", strconv.FormatInt(ts, 10)) 239 | } 240 | 241 | func (r *RedisClient) formatKey(args ...interface{}) string { 242 | return join(r.prefix, join(args...)) 243 | } 244 | 245 | func (r *RedisClient) formatRound(height int64, nonce string) string { 246 | return r.formatKey("shares", "round"+strconv.FormatInt(height, 10), nonce) 247 | } 248 | 249 | func join(args ...interface{}) string { 250 | s := make([]string, len(args)) 251 | for i, v := range args { 252 | switch v.(type) { 253 | case string: 254 | s[i] = v.(string) 255 | case int64: 256 | s[i] = strconv.FormatInt(v.(int64), 10) 257 | case uint64: 258 | s[i] = strconv.FormatUint(v.(uint64), 10) 259 | case float64: 260 | s[i] = strconv.FormatFloat(v.(float64), 'f', 0, 64) 261 | case bool: 262 | if v.(bool) { 263 | s[i] = "1" 264 | } else { 265 | s[i] = "0" 266 | } 267 | case *big.Int: 268 | n := v.(*big.Int) 269 | if n != nil { 270 | s[i] = n.String() 271 | } else { 272 | s[i] = "0" 273 | } 274 | default: 275 | panic("Invalid type specified for conversion") 276 | } 277 | } 278 | return strings.Join(s, ":") 279 | } 280 | 281 | func (r *RedisClient) GetCandidates(maxHeight int64) ([]*BlockData, error) { 282 | option := redis.ZRangeByScore{Min: "0", Max: strconv.FormatInt(maxHeight, 10)} 283 | cmd := r.client.ZRangeByScoreWithScores(r.formatKey("blocks", "candidates"), option) 284 | if cmd.Err() != nil { 285 | return nil, cmd.Err() 286 | } 287 | return convertCandidateResults(cmd), nil 288 | } 289 | 290 | func (r *RedisClient) GetImmatureBlocks(maxHeight int64) ([]*BlockData, error) { 291 | option := redis.ZRangeByScore{Min: "0", Max: strconv.FormatInt(maxHeight, 10)} 292 | cmd := r.client.ZRangeByScoreWithScores(r.formatKey("blocks", "immature"), option) 293 | if cmd.Err() != nil { 294 | return nil, cmd.Err() 295 | } 296 | return convertBlockResults(cmd), nil 297 | } 298 | 299 | func (r *RedisClient) GetRoundShares(height int64, nonce string) (map[string]int64, error) { 300 | result := make(map[string]int64) 301 | cmd := r.client.HGetAllMap(r.formatRound(height, nonce)) 302 | if cmd.Err() != nil { 303 | return nil, cmd.Err() 304 | } 305 | sharesMap, _ := cmd.Result() 306 | for login, v := range sharesMap { 307 | n, _ := strconv.ParseInt(v, 10, 64) 308 | result[login] = n 309 | } 310 | return result, nil 311 | } 312 | 313 | func (r *RedisClient) GetPayees() ([]string, error) { 314 | payees := make(map[string]struct{}) 315 | var result []string 316 | var c int64 317 | 318 | for { 319 | var keys []string 320 | var err error 321 | c, keys, err = r.client.Scan(c, r.formatKey("miners", "*"), 100).Result() 322 | if err != nil { 323 | return nil, err 324 | } 325 | for _, row := range keys { 326 | login := strings.Split(row, ":")[2] 327 | payees[login] = struct{}{} 328 | } 329 | if c == 0 { 330 | break 331 | } 332 | } 333 | for login, _ := range payees { 334 | result = append(result, login) 335 | } 336 | return result, nil 337 | } 338 | 339 | func (r *RedisClient) GetBalance(login string) (int64, error) { 340 | cmd := r.client.HGet(r.formatKey("miners", login), "balance") 341 | if cmd.Err() == redis.Nil { 342 | return 0, nil 343 | } else if cmd.Err() != nil { 344 | return 0, cmd.Err() 345 | } 346 | return cmd.Int64() 347 | } 348 | 349 | func (r *RedisClient) LockPayouts(login string, amount int64) error { 350 | key := r.formatKey("payments", "lock") 351 | result := r.client.SetNX(key, join(login, amount), 0).Val() 352 | if !result { 353 | return fmt.Errorf("Unable to acquire lock '%s'", key) 354 | } 355 | return nil 356 | } 357 | 358 | func (r *RedisClient) UnlockPayouts() error { 359 | key := r.formatKey("payments", "lock") 360 | _, err := r.client.Del(key).Result() 361 | return err 362 | } 363 | 364 | func (r *RedisClient) IsPayoutsLocked() (bool, error) { 365 | _, err := r.client.Get(r.formatKey("payments", "lock")).Result() 366 | if err == redis.Nil { 367 | return false, nil 368 | } else if err != nil { 369 | return false, err 370 | } else { 371 | return true, nil 372 | } 373 | } 374 | 375 | type PendingPayment struct { 376 | Timestamp int64 `json:"timestamp"` 377 | Amount int64 `json:"amount"` 378 | Address string `json:"login"` 379 | } 380 | 381 | func (r *RedisClient) GetPendingPayments() []*PendingPayment { 382 | raw := r.client.ZRevRangeWithScores(r.formatKey("payments", "pending"), 0, -1) 383 | var result []*PendingPayment 384 | for _, v := range raw.Val() { 385 | // timestamp -> "address:amount" 386 | payment := PendingPayment{} 387 | payment.Timestamp = int64(v.Score) 388 | fields := strings.Split(v.Member.(string), ":") 389 | payment.Address = fields[0] 390 | payment.Amount, _ = strconv.ParseInt(fields[1], 10, 64) 391 | result = append(result, &payment) 392 | } 393 | return result 394 | } 395 | 396 | // Deduct miner's balance for payment 397 | func (r *RedisClient) UpdateBalance(login string, amount int64) error { 398 | tx := r.client.Multi() 399 | defer tx.Close() 400 | 401 | ts := util.MakeTimestamp() / 1000 402 | 403 | _, err := tx.Exec(func() error { 404 | tx.HIncrBy(r.formatKey("miners", login), "balance", (amount * -1)) 405 | tx.HIncrBy(r.formatKey("miners", login), "pending", amount) 406 | tx.HIncrBy(r.formatKey("finances"), "balance", (amount * -1)) 407 | tx.HIncrBy(r.formatKey("finances"), "pending", amount) 408 | tx.ZAdd(r.formatKey("payments", "pending"), redis.Z{Score: float64(ts), Member: join(login, amount)}) 409 | return nil 410 | }) 411 | return err 412 | } 413 | 414 | func (r *RedisClient) RollbackBalance(login string, amount int64) error { 415 | tx := r.client.Multi() 416 | defer tx.Close() 417 | 418 | _, err := tx.Exec(func() error { 419 | tx.HIncrBy(r.formatKey("miners", login), "balance", amount) 420 | tx.HIncrBy(r.formatKey("miners", login), "pending", (amount * -1)) 421 | tx.HIncrBy(r.formatKey("finances"), "balance", amount) 422 | tx.HIncrBy(r.formatKey("finances"), "pending", (amount * -1)) 423 | tx.ZRem(r.formatKey("payments", "pending"), join(login, amount)) 424 | return nil 425 | }) 426 | return err 427 | } 428 | 429 | func (r *RedisClient) WritePayment(login, txHash string, amount int64) error { 430 | tx := r.client.Multi() 431 | defer tx.Close() 432 | 433 | ts := util.MakeTimestamp() / 1000 434 | 435 | _, err := tx.Exec(func() error { 436 | tx.HIncrBy(r.formatKey("miners", login), "pending", (amount * -1)) 437 | tx.HIncrBy(r.formatKey("miners", login), "paid", amount) 438 | tx.HIncrBy(r.formatKey("finances"), "pending", (amount * -1)) 439 | tx.HIncrBy(r.formatKey("finances"), "paid", amount) 440 | tx.ZAdd(r.formatKey("payments", "all"), redis.Z{Score: float64(ts), Member: join(txHash, login, amount)}) 441 | tx.ZAdd(r.formatKey("payments", login), redis.Z{Score: float64(ts), Member: join(txHash, amount)}) 442 | tx.ZRem(r.formatKey("payments", "pending"), join(login, amount)) 443 | tx.Del(r.formatKey("payments", "lock")) 444 | return nil 445 | }) 446 | return err 447 | } 448 | 449 | func (r *RedisClient) WriteImmatureBlock(block *BlockData, roundRewards map[string]int64) error { 450 | tx := r.client.Multi() 451 | defer tx.Close() 452 | 453 | _, err := tx.Exec(func() error { 454 | r.writeImmatureBlock(tx, block) 455 | total := int64(0) 456 | for login, amount := range roundRewards { 457 | total += amount 458 | tx.HIncrBy(r.formatKey("miners", login), "immature", amount) 459 | tx.HSetNX(r.formatKey("credits", "immature", block.Height, block.Hash), login, strconv.FormatInt(amount, 10)) 460 | } 461 | tx.HIncrBy(r.formatKey("finances"), "immature", total) 462 | return nil 463 | }) 464 | return err 465 | } 466 | 467 | func (r *RedisClient) WriteMaturedBlock(block *BlockData, roundRewards map[string]int64) error { 468 | creditKey := r.formatKey("credits", "immature", block.RoundHeight, block.Hash) 469 | tx, err := r.client.Watch(creditKey) 470 | // Must decrement immatures using existing log entry 471 | immatureCredits := tx.HGetAllMap(creditKey) 472 | if err != nil { 473 | return err 474 | } 475 | defer tx.Close() 476 | 477 | ts := util.MakeTimestamp() / 1000 478 | value := join(block.Hash, ts, block.Reward) 479 | 480 | _, err = tx.Exec(func() error { 481 | r.writeMaturedBlock(tx, block) 482 | tx.ZAdd(r.formatKey("credits", "all"), redis.Z{Score: float64(block.Height), Member: value}) 483 | 484 | // Decrement immature balances 485 | totalImmature := int64(0) 486 | for login, amountString := range immatureCredits.Val() { 487 | amount, _ := strconv.ParseInt(amountString, 10, 64) 488 | totalImmature += amount 489 | tx.HIncrBy(r.formatKey("miners", login), "immature", (amount * -1)) 490 | } 491 | 492 | // Increment balances 493 | total := int64(0) 494 | for login, amount := range roundRewards { 495 | total += amount 496 | // NOTICE: Maybe expire round reward entry in 604800 (a week)? 497 | tx.HIncrBy(r.formatKey("miners", login), "balance", amount) 498 | tx.HSetNX(r.formatKey("credits", block.Height, block.Hash), login, strconv.FormatInt(amount, 10)) 499 | } 500 | tx.Del(creditKey) 501 | tx.HIncrBy(r.formatKey("finances"), "balance", total) 502 | tx.HIncrBy(r.formatKey("finances"), "immature", (totalImmature * -1)) 503 | tx.HSet(r.formatKey("finances"), "lastCreditHeight", strconv.FormatInt(block.Height, 10)) 504 | tx.HSet(r.formatKey("finances"), "lastCreditHash", block.Hash) 505 | tx.HIncrBy(r.formatKey("finances"), "totalMined", block.RewardInShannon()) 506 | return nil 507 | }) 508 | return err 509 | } 510 | 511 | func (r *RedisClient) WriteOrphan(block *BlockData) error { 512 | creditKey := r.formatKey("credits", "immature", block.RoundHeight, block.Hash) 513 | tx, err := r.client.Watch(creditKey) 514 | // Must decrement immatures using existing log entry 515 | immatureCredits := tx.HGetAllMap(creditKey) 516 | if err != nil { 517 | return err 518 | } 519 | defer tx.Close() 520 | 521 | _, err = tx.Exec(func() error { 522 | r.writeMaturedBlock(tx, block) 523 | 524 | // Decrement immature balances 525 | totalImmature := int64(0) 526 | for login, amountString := range immatureCredits.Val() { 527 | amount, _ := strconv.ParseInt(amountString, 10, 64) 528 | totalImmature += amount 529 | tx.HIncrBy(r.formatKey("miners", login), "immature", (amount * -1)) 530 | } 531 | tx.Del(creditKey) 532 | tx.HIncrBy(r.formatKey("finances"), "immature", (totalImmature * -1)) 533 | return nil 534 | }) 535 | return err 536 | } 537 | 538 | func (r *RedisClient) WritePendingOrphans(blocks []*BlockData) error { 539 | tx := r.client.Multi() 540 | defer tx.Close() 541 | 542 | _, err := tx.Exec(func() error { 543 | for _, block := range blocks { 544 | r.writeImmatureBlock(tx, block) 545 | } 546 | return nil 547 | }) 548 | return err 549 | } 550 | 551 | func (r *RedisClient) writeImmatureBlock(tx *redis.Multi, block *BlockData) { 552 | // Redis 2.8.x returns "ERR source and destination objects are the same" 553 | if block.Height != block.RoundHeight { 554 | tx.Rename(r.formatRound(block.RoundHeight, block.Nonce), r.formatRound(block.Height, block.Nonce)) 555 | } 556 | tx.ZRem(r.formatKey("blocks", "candidates"), block.candidateKey) 557 | tx.ZAdd(r.formatKey("blocks", "immature"), redis.Z{Score: float64(block.Height), Member: block.key()}) 558 | } 559 | 560 | func (r *RedisClient) writeMaturedBlock(tx *redis.Multi, block *BlockData) { 561 | tx.Del(r.formatRound(block.RoundHeight, block.Nonce)) 562 | tx.ZRem(r.formatKey("blocks", "immature"), block.immatureKey) 563 | tx.ZAdd(r.formatKey("blocks", "matured"), redis.Z{Score: float64(block.Height), Member: block.key()}) 564 | } 565 | 566 | func (r *RedisClient) IsMinerExists(login string) (bool, error) { 567 | return r.client.Exists(r.formatKey("miners", login)).Result() 568 | } 569 | 570 | func (r *RedisClient) GetMinerStats(login string, maxPayments int64) (map[string]interface{}, error) { 571 | stats := make(map[string]interface{}) 572 | 573 | tx := r.client.Multi() 574 | defer tx.Close() 575 | 576 | cmds, err := tx.Exec(func() error { 577 | tx.HGetAllMap(r.formatKey("miners", login)) 578 | tx.ZRevRangeWithScores(r.formatKey("payments", login), 0, maxPayments-1) 579 | tx.ZCard(r.formatKey("payments", login)) 580 | tx.HGet(r.formatKey("shares", "roundCurrent"), login) 581 | return nil 582 | }) 583 | 584 | if err != nil && err != redis.Nil { 585 | return nil, err 586 | } else { 587 | result, _ := cmds[0].(*redis.StringStringMapCmd).Result() 588 | stats["stats"] = convertStringMap(result) 589 | payments := convertPaymentsResults(cmds[1].(*redis.ZSliceCmd)) 590 | stats["payments"] = payments 591 | stats["paymentsTotal"] = cmds[2].(*redis.IntCmd).Val() 592 | roundShares, _ := cmds[3].(*redis.StringCmd).Int64() 593 | stats["roundShares"] = roundShares 594 | } 595 | 596 | return stats, nil 597 | } 598 | 599 | // Try to convert all numeric strings to int64 600 | func convertStringMap(m map[string]string) map[string]interface{} { 601 | result := make(map[string]interface{}) 602 | var err error 603 | for k, v := range m { 604 | result[k], err = strconv.ParseInt(v, 10, 64) 605 | if err != nil { 606 | result[k] = v 607 | } 608 | } 609 | return result 610 | } 611 | 612 | // WARNING: Must run it periodically to flush out of window hashrate entries 613 | func (r *RedisClient) FlushStaleStats(window, largeWindow time.Duration) (int64, error) { 614 | now := util.MakeTimestamp() / 1000 615 | max := fmt.Sprint("(", now-int64(window/time.Second)) 616 | total, err := r.client.ZRemRangeByScore(r.formatKey("hashrate"), "-inf", max).Result() 617 | if err != nil { 618 | return total, err 619 | } 620 | 621 | var c int64 622 | miners := make(map[string]struct{}) 623 | max = fmt.Sprint("(", now-int64(largeWindow/time.Second)) 624 | 625 | for { 626 | var keys []string 627 | var err error 628 | c, keys, err = r.client.Scan(c, r.formatKey("hashrate", "*"), 100).Result() 629 | if err != nil { 630 | return total, err 631 | } 632 | for _, row := range keys { 633 | login := strings.Split(row, ":")[2] 634 | if _, ok := miners[login]; !ok { 635 | n, err := r.client.ZRemRangeByScore(r.formatKey("hashrate", login), "-inf", max).Result() 636 | if err != nil { 637 | return total, err 638 | } 639 | miners[login] = struct{}{} 640 | total += n 641 | } 642 | } 643 | if c == 0 { 644 | break 645 | } 646 | } 647 | return total, nil 648 | } 649 | 650 | func (r *RedisClient) CollectStats(smallWindow time.Duration, maxBlocks, maxPayments int64) (map[string]interface{}, error) { 651 | window := int64(smallWindow / time.Second) 652 | stats := make(map[string]interface{}) 653 | 654 | tx := r.client.Multi() 655 | defer tx.Close() 656 | 657 | now := util.MakeTimestamp() / 1000 658 | 659 | cmds, err := tx.Exec(func() error { 660 | tx.ZRemRangeByScore(r.formatKey("hashrate"), "-inf", fmt.Sprint("(", now-window)) 661 | tx.ZRangeWithScores(r.formatKey("hashrate"), 0, -1) 662 | tx.HGetAllMap(r.formatKey("stats")) 663 | tx.ZRevRangeWithScores(r.formatKey("blocks", "candidates"), 0, -1) 664 | tx.ZRevRangeWithScores(r.formatKey("blocks", "immature"), 0, -1) 665 | tx.ZRevRangeWithScores(r.formatKey("blocks", "matured"), 0, maxBlocks-1) 666 | tx.ZCard(r.formatKey("blocks", "candidates")) 667 | tx.ZCard(r.formatKey("blocks", "immature")) 668 | tx.ZCard(r.formatKey("blocks", "matured")) 669 | tx.ZCard(r.formatKey("payments", "all")) 670 | tx.ZRevRangeWithScores(r.formatKey("payments", "all"), 0, maxPayments-1) 671 | return nil 672 | }) 673 | 674 | if err != nil { 675 | return nil, err 676 | } 677 | 678 | result, _ := cmds[2].(*redis.StringStringMapCmd).Result() 679 | stats["stats"] = convertStringMap(result) 680 | candidates := convertCandidateResults(cmds[3].(*redis.ZSliceCmd)) 681 | stats["candidates"] = candidates 682 | stats["candidatesTotal"] = cmds[6].(*redis.IntCmd).Val() 683 | 684 | immature := convertBlockResults(cmds[4].(*redis.ZSliceCmd)) 685 | stats["immature"] = immature 686 | stats["immatureTotal"] = cmds[7].(*redis.IntCmd).Val() 687 | 688 | matured := convertBlockResults(cmds[5].(*redis.ZSliceCmd)) 689 | stats["matured"] = matured 690 | stats["maturedTotal"] = cmds[8].(*redis.IntCmd).Val() 691 | 692 | payments := convertPaymentsResults(cmds[10].(*redis.ZSliceCmd)) 693 | stats["payments"] = payments 694 | stats["paymentsTotal"] = cmds[9].(*redis.IntCmd).Val() 695 | 696 | totalHashrate, miners := convertMinersStats(window, cmds[1].(*redis.ZSliceCmd)) 697 | stats["miners"] = miners 698 | stats["minersTotal"] = len(miners) 699 | stats["hashrate"] = totalHashrate 700 | return stats, nil 701 | } 702 | 703 | func (r *RedisClient) CollectWorkersStats(sWindow, lWindow time.Duration, login string) (map[string]interface{}, error) { 704 | smallWindow := int64(sWindow / time.Second) 705 | largeWindow := int64(lWindow / time.Second) 706 | stats := make(map[string]interface{}) 707 | 708 | tx := r.client.Multi() 709 | defer tx.Close() 710 | 711 | now := util.MakeTimestamp() / 1000 712 | 713 | cmds, err := tx.Exec(func() error { 714 | tx.ZRemRangeByScore(r.formatKey("hashrate", login), "-inf", fmt.Sprint("(", now-largeWindow)) 715 | tx.ZRangeWithScores(r.formatKey("hashrate", login), 0, -1) 716 | return nil 717 | }) 718 | 719 | if err != nil { 720 | return nil, err 721 | } 722 | 723 | totalHashrate := int64(0) 724 | currentHashrate := int64(0) 725 | online := int64(0) 726 | offline := int64(0) 727 | workers := convertWorkersStats(smallWindow, cmds[1].(*redis.ZSliceCmd)) 728 | 729 | for id, worker := range workers { 730 | timeOnline := now - worker.startedAt 731 | if timeOnline < 600 { 732 | timeOnline = 600 733 | } 734 | 735 | boundary := timeOnline 736 | if timeOnline >= smallWindow { 737 | boundary = smallWindow 738 | } 739 | worker.HR = worker.HR / boundary 740 | 741 | boundary = timeOnline 742 | if timeOnline >= largeWindow { 743 | boundary = largeWindow 744 | } 745 | worker.TotalHR = worker.TotalHR / boundary 746 | 747 | if worker.LastBeat < (now - smallWindow/2) { 748 | worker.Offline = true 749 | offline++ 750 | } else { 751 | online++ 752 | } 753 | 754 | currentHashrate += worker.HR 755 | totalHashrate += worker.TotalHR 756 | workers[id] = worker 757 | } 758 | stats["workers"] = workers 759 | stats["workersTotal"] = len(workers) 760 | stats["workersOnline"] = online 761 | stats["workersOffline"] = offline 762 | stats["hashrate"] = totalHashrate 763 | stats["currentHashrate"] = currentHashrate 764 | return stats, nil 765 | } 766 | 767 | func (r *RedisClient) CollectLuckStats(windows []int) (map[string]interface{}, error) { 768 | stats := make(map[string]interface{}) 769 | 770 | tx := r.client.Multi() 771 | defer tx.Close() 772 | 773 | max := int64(windows[len(windows)-1]) 774 | 775 | cmds, err := tx.Exec(func() error { 776 | tx.ZRevRangeWithScores(r.formatKey("blocks", "immature"), 0, -1) 777 | tx.ZRevRangeWithScores(r.formatKey("blocks", "matured"), 0, max-1) 778 | return nil 779 | }) 780 | if err != nil { 781 | return stats, err 782 | } 783 | blocks := convertBlockResults(cmds[0].(*redis.ZSliceCmd), cmds[1].(*redis.ZSliceCmd)) 784 | 785 | calcLuck := func(max int) (int, float64, float64, float64) { 786 | var total int 787 | var sharesDiff, uncles, orphans float64 788 | for i, block := range blocks { 789 | if i > (max - 1) { 790 | break 791 | } 792 | if block.Uncle { 793 | uncles++ 794 | } 795 | if block.Orphan { 796 | orphans++ 797 | } 798 | sharesDiff += float64(block.TotalShares) / float64(block.Difficulty) 799 | total++ 800 | } 801 | if total > 0 { 802 | sharesDiff /= float64(total) 803 | uncles /= float64(total) 804 | orphans /= float64(total) 805 | } 806 | return total, sharesDiff, uncles, orphans 807 | } 808 | for _, max := range windows { 809 | total, sharesDiff, uncleRate, orphanRate := calcLuck(max) 810 | row := map[string]float64{ 811 | "luck": sharesDiff, "uncleRate": uncleRate, "orphanRate": orphanRate, 812 | } 813 | stats[strconv.Itoa(total)] = row 814 | if total < max { 815 | break 816 | } 817 | } 818 | return stats, nil 819 | } 820 | 821 | func convertCandidateResults(raw *redis.ZSliceCmd) []*BlockData { 822 | var result []*BlockData 823 | for _, v := range raw.Val() { 824 | // "nonce:powHash:mixDigest:timestamp:diff:totalShares" 825 | block := BlockData{} 826 | block.Height = int64(v.Score) 827 | block.RoundHeight = block.Height 828 | fields := strings.Split(v.Member.(string), ":") 829 | block.Nonce = fields[0] 830 | block.PowHash = fields[1] 831 | block.MixDigest = fields[2] 832 | block.Timestamp, _ = strconv.ParseInt(fields[3], 10, 64) 833 | block.Difficulty, _ = strconv.ParseInt(fields[4], 10, 64) 834 | block.TotalShares, _ = strconv.ParseInt(fields[5], 10, 64) 835 | block.candidateKey = v.Member.(string) 836 | result = append(result, &block) 837 | } 838 | return result 839 | } 840 | 841 | func convertBlockResults(rows ...*redis.ZSliceCmd) []*BlockData { 842 | var result []*BlockData 843 | for _, row := range rows { 844 | for _, v := range row.Val() { 845 | // "uncleHeight:orphan:nonce:blockHash:timestamp:diff:totalShares:rewardInWei" 846 | block := BlockData{} 847 | block.Height = int64(v.Score) 848 | block.RoundHeight = block.Height 849 | fields := strings.Split(v.Member.(string), ":") 850 | block.UncleHeight, _ = strconv.ParseInt(fields[0], 10, 64) 851 | block.Uncle = block.UncleHeight > 0 852 | block.Orphan, _ = strconv.ParseBool(fields[1]) 853 | block.Nonce = fields[2] 854 | block.Hash = fields[3] 855 | block.Timestamp, _ = strconv.ParseInt(fields[4], 10, 64) 856 | block.Difficulty, _ = strconv.ParseInt(fields[5], 10, 64) 857 | block.TotalShares, _ = strconv.ParseInt(fields[6], 10, 64) 858 | block.RewardString = fields[7] 859 | block.ImmatureReward = fields[7] 860 | block.immatureKey = v.Member.(string) 861 | result = append(result, &block) 862 | } 863 | } 864 | return result 865 | } 866 | 867 | // Build per login workers's total shares map {'rig-1': 12345, 'rig-2': 6789, ...} 868 | // TS => diff, id, ms 869 | func convertWorkersStats(window int64, raw *redis.ZSliceCmd) map[string]Worker { 870 | now := util.MakeTimestamp() / 1000 871 | workers := make(map[string]Worker) 872 | 873 | for _, v := range raw.Val() { 874 | parts := strings.Split(v.Member.(string), ":") 875 | share, _ := strconv.ParseInt(parts[0], 10, 64) 876 | id := parts[1] 877 | score := int64(v.Score) 878 | worker := workers[id] 879 | 880 | // Add for large window 881 | worker.TotalHR += share 882 | 883 | // Add for small window if matches 884 | if score >= now-window { 885 | worker.HR += share 886 | } 887 | 888 | if worker.LastBeat < score { 889 | worker.LastBeat = score 890 | } 891 | if worker.startedAt > score || worker.startedAt == 0 { 892 | worker.startedAt = score 893 | } 894 | workers[id] = worker 895 | } 896 | return workers 897 | } 898 | 899 | func convertMinersStats(window int64, raw *redis.ZSliceCmd) (int64, map[string]Miner) { 900 | now := util.MakeTimestamp() / 1000 901 | miners := make(map[string]Miner) 902 | totalHashrate := int64(0) 903 | 904 | for _, v := range raw.Val() { 905 | parts := strings.Split(v.Member.(string), ":") 906 | share, _ := strconv.ParseInt(parts[0], 10, 64) 907 | id := parts[1] 908 | score := int64(v.Score) 909 | miner := miners[id] 910 | miner.HR += share 911 | 912 | if miner.LastBeat < score { 913 | miner.LastBeat = score 914 | } 915 | if miner.startedAt > score || miner.startedAt == 0 { 916 | miner.startedAt = score 917 | } 918 | miners[id] = miner 919 | } 920 | 921 | for id, miner := range miners { 922 | timeOnline := now - miner.startedAt 923 | if timeOnline < 600 { 924 | timeOnline = 600 925 | } 926 | 927 | boundary := timeOnline 928 | if timeOnline >= window { 929 | boundary = window 930 | } 931 | miner.HR = miner.HR / boundary 932 | 933 | if miner.LastBeat < (now - window/2) { 934 | miner.Offline = true 935 | } 936 | totalHashrate += miner.HR 937 | miners[id] = miner 938 | } 939 | return totalHashrate, miners 940 | } 941 | 942 | func convertPaymentsResults(raw *redis.ZSliceCmd) []map[string]interface{} { 943 | var result []map[string]interface{} 944 | for _, v := range raw.Val() { 945 | tx := make(map[string]interface{}) 946 | tx["timestamp"] = int64(v.Score) 947 | fields := strings.Split(v.Member.(string), ":") 948 | tx["tx"] = fields[0] 949 | // Individual or whole payments row 950 | if len(fields) < 3 { 951 | tx["amount"], _ = strconv.ParseInt(fields[1], 10, 64) 952 | } else { 953 | tx["address"] = fields[1] 954 | tx["amount"], _ = strconv.ParseInt(fields[2], 10, 64) 955 | } 956 | result = append(result, tx) 957 | } 958 | return result 959 | } 960 | -------------------------------------------------------------------------------- /storage/redis_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "strconv" 7 | "testing" 8 | 9 | "gopkg.in/redis.v3" 10 | ) 11 | 12 | var r *RedisClient 13 | 14 | const prefix = "test" 15 | 16 | func TestMain(m *testing.M) { 17 | r = NewRedisClient(&Config{Endpoint: "127.0.0.1:6379"}, prefix) 18 | reset() 19 | c := m.Run() 20 | reset() 21 | os.Exit(c) 22 | } 23 | 24 | func TestWriteShareCheckExist(t *testing.T) { 25 | reset() 26 | 27 | exist, _ := r.WriteShare("x", "x", []string{"0x0", "0x0", "0x0"}, 10, 1008, 0) 28 | if exist { 29 | t.Error("PoW must not exist") 30 | } 31 | exist, _ = r.WriteShare("x", "x", []string{"0x0", "0x1", "0x0"}, 10, 1008, 0) 32 | if exist { 33 | t.Error("PoW must not exist") 34 | } 35 | exist, _ = r.WriteShare("x", "x", []string{"0x0", "0x0", "0x1"}, 100, 1010, 0) 36 | if exist { 37 | t.Error("PoW must not exist") 38 | } 39 | exist, _ = r.WriteShare("z", "x", []string{"0x0", "0x0", "0x1"}, 100, 1016, 0) 40 | if !exist { 41 | t.Error("PoW must exist") 42 | } 43 | exist, _ = r.WriteShare("x", "x", []string{"0x0", "0x0", "0x1"}, 100, 1025, 0) 44 | if exist { 45 | t.Error("PoW must not exist") 46 | } 47 | } 48 | 49 | func TestGetPayees(t *testing.T) { 50 | reset() 51 | 52 | n := 256 53 | for i := 0; i < n; i++ { 54 | r.client.HSet(r.formatKey("miners", strconv.Itoa(i)), "balance", strconv.Itoa(i)) 55 | } 56 | 57 | var payees []string 58 | payees, _ = r.GetPayees() 59 | if len(payees) != n { 60 | t.Error("Must return all payees") 61 | } 62 | m := make(map[string]struct{}) 63 | for _, v := range payees { 64 | m[v] = struct{}{} 65 | } 66 | if len(m) != n { 67 | t.Error("Must be unique list") 68 | } 69 | } 70 | 71 | func TestGetBalance(t *testing.T) { 72 | reset() 73 | 74 | r.client.HSet(r.formatKey("miners:x"), "balance", "750") 75 | 76 | v, _ := r.GetBalance("x") 77 | if v != 750 { 78 | t.Error("Must return balance") 79 | } 80 | 81 | v, err := r.GetBalance("z") 82 | if v != 0 { 83 | t.Error("Must return 0 if account does not exist") 84 | } 85 | if err != nil { 86 | t.Error("Must not return error if account does not exist") 87 | } 88 | } 89 | 90 | func TestLockPayouts(t *testing.T) { 91 | reset() 92 | 93 | r.LockPayouts("x", 1000) 94 | v := r.client.Get("test:payments:lock").Val() 95 | if v != "x:1000" { 96 | t.Errorf("Invalid lock amount: %v", v) 97 | } 98 | 99 | err := r.LockPayouts("x", 100) 100 | if err == nil { 101 | t.Errorf("Must not overwrite lock") 102 | } 103 | } 104 | 105 | func TestUnlockPayouts(t *testing.T) { 106 | reset() 107 | 108 | r.client.Set(r.formatKey("payments:lock"), "x:1000", 0) 109 | 110 | r.UnlockPayouts() 111 | err := r.client.Get(r.formatKey("payments:lock")).Err() 112 | if err != redis.Nil { 113 | t.Errorf("Must release lock") 114 | } 115 | } 116 | 117 | func TestIsPayoutsLocked(t *testing.T) { 118 | reset() 119 | 120 | r.LockPayouts("x", 1000) 121 | if locked, _ := r.IsPayoutsLocked(); !locked { 122 | t.Errorf("Payouts must be locked") 123 | } 124 | } 125 | 126 | func TestUpdateBalance(t *testing.T) { 127 | reset() 128 | 129 | r.client.HMSetMap( 130 | r.formatKey("miners:x"), 131 | map[string]string{"paid": "50", "balance": "1000"}, 132 | ) 133 | r.client.HMSetMap( 134 | r.formatKey("finances"), 135 | map[string]string{"paid": "500", "balance": "10000"}, 136 | ) 137 | 138 | amount := int64(250) 139 | r.UpdateBalance("x", amount) 140 | result := r.client.HGetAllMap(r.formatKey("miners:x")).Val() 141 | if result["pending"] != "250" { 142 | t.Error("Must set pending amount") 143 | } 144 | if result["balance"] != "750" { 145 | t.Error("Must deduct balance") 146 | } 147 | if result["paid"] != "50" { 148 | t.Error("Must not touch paid") 149 | } 150 | 151 | result = r.client.HGetAllMap(r.formatKey("finances")).Val() 152 | if result["pending"] != "250" { 153 | t.Error("Must set pool pending amount") 154 | } 155 | if result["balance"] != "9750" { 156 | t.Error("Must deduct pool balance") 157 | } 158 | if result["paid"] != "500" { 159 | t.Error("Must not touch pool paid") 160 | } 161 | 162 | rank := r.client.ZRank(r.formatKey("payments:pending"), join("x", amount)).Val() 163 | if rank != 0 { 164 | t.Error("Must add pending payment") 165 | } 166 | } 167 | 168 | func TestRollbackBalance(t *testing.T) { 169 | reset() 170 | 171 | r.client.HMSetMap( 172 | r.formatKey("miners:x"), 173 | map[string]string{"paid": "100", "balance": "750", "pending": "250"}, 174 | ) 175 | r.client.HMSetMap( 176 | r.formatKey("finances"), 177 | map[string]string{"paid": "500", "balance": "10000", "pending": "250"}, 178 | ) 179 | r.client.ZAdd(r.formatKey("payments:pending"), redis.Z{Score: 1, Member: "xx"}) 180 | 181 | amount := int64(250) 182 | r.RollbackBalance("x", amount) 183 | result := r.client.HGetAllMap(r.formatKey("miners:x")).Val() 184 | if result["paid"] != "100" { 185 | t.Error("Must not touch paid") 186 | } 187 | if result["balance"] != "1000" { 188 | t.Error("Must increase balance") 189 | } 190 | if result["pending"] != "0" { 191 | t.Error("Must deduct pending") 192 | } 193 | 194 | result = r.client.HGetAllMap(r.formatKey("finances")).Val() 195 | if result["paid"] != "500" { 196 | t.Error("Must not touch pool paid") 197 | } 198 | if result["balance"] != "10250" { 199 | t.Error("Must increase pool balance") 200 | } 201 | if result["pending"] != "0" { 202 | t.Error("Must deduct pool pending") 203 | } 204 | 205 | err := r.client.ZRank(r.formatKey("payments:pending"), join("x", amount)).Err() 206 | if err != redis.Nil { 207 | t.Errorf("Must remove pending payment") 208 | } 209 | } 210 | 211 | func TestWritePayment(t *testing.T) { 212 | reset() 213 | 214 | r.client.HMSetMap( 215 | r.formatKey("miners:x"), 216 | map[string]string{"paid": "50", "balance": "1000", "pending": "250"}, 217 | ) 218 | r.client.HMSetMap( 219 | r.formatKey("finances"), 220 | map[string]string{"paid": "500", "balance": "10000", "pending": "250"}, 221 | ) 222 | 223 | amount := int64(250) 224 | r.WritePayment("x", "0x0", amount) 225 | result := r.client.HGetAllMap(r.formatKey("miners:x")).Val() 226 | if result["pending"] != "0" { 227 | t.Error("Must unset pending amount") 228 | } 229 | if result["balance"] != "1000" { 230 | t.Error("Must not touch balance") 231 | } 232 | if result["paid"] != "300" { 233 | t.Error("Must increase paid") 234 | } 235 | 236 | result = r.client.HGetAllMap(r.formatKey("finances")).Val() 237 | if result["pending"] != "0" { 238 | t.Error("Must deduct pool pending amount") 239 | } 240 | if result["balance"] != "10000" { 241 | t.Error("Must not touch pool balance") 242 | } 243 | if result["paid"] != "750" { 244 | t.Error("Must increase pool paid") 245 | } 246 | 247 | err := r.client.Get(r.formatKey("payments:lock")).Err() 248 | if err != redis.Nil { 249 | t.Errorf("Must release lock") 250 | } 251 | 252 | err = r.client.ZRank(r.formatKey("payments:pending"), join("x", amount)).Err() 253 | if err != redis.Nil { 254 | t.Error("Must remove pending payment") 255 | } 256 | err = r.client.ZRank(r.formatKey("payments:all"), join("0x0", "x", amount)).Err() 257 | if err == redis.Nil { 258 | t.Error("Must add payment to set") 259 | } 260 | err = r.client.ZRank(r.formatKey("payments:x"), join("0x0", amount)).Err() 261 | if err == redis.Nil { 262 | t.Error("Must add payment to set") 263 | } 264 | } 265 | 266 | func TestGetPendingPayments(t *testing.T) { 267 | reset() 268 | 269 | r.client.HMSetMap( 270 | r.formatKey("miners:x"), 271 | map[string]string{"paid": "100", "balance": "750", "pending": "250"}, 272 | ) 273 | 274 | amount := int64(1000) 275 | r.UpdateBalance("x", amount) 276 | pending := r.GetPendingPayments() 277 | 278 | if len(pending) != 1 { 279 | t.Error("Must return pending payment") 280 | } 281 | if pending[0].Amount != amount { 282 | t.Error("Must have corrent amount") 283 | } 284 | if pending[0].Address != "x" { 285 | t.Error("Must have corrent account") 286 | } 287 | if pending[0].Timestamp <= 0 { 288 | t.Error("Must have timestamp") 289 | } 290 | } 291 | 292 | func TestCollectLuckStats(t *testing.T) { 293 | reset() 294 | 295 | members := []redis.Z{ 296 | redis.Z{Score: 0, Member: "1:0:0x0:0x0:0:100:100:0"}, 297 | } 298 | r.client.ZAdd(r.formatKey("blocks:immature"), members...) 299 | members = []redis.Z{ 300 | redis.Z{Score: 1, Member: "1:0:0x2:0x0:0:50:100:0"}, 301 | redis.Z{Score: 2, Member: "0:1:0x1:0x0:0:100:100:0"}, 302 | redis.Z{Score: 3, Member: "0:0:0x3:0x0:0:200:100:0"}, 303 | } 304 | r.client.ZAdd(r.formatKey("blocks:matured"), members...) 305 | 306 | stats, _ := r.CollectLuckStats([]int{1, 2, 5, 10}) 307 | expectedStats := map[string]interface{}{ 308 | "1": map[string]float64{ 309 | "luck": 1, "uncleRate": 1, "orphanRate": 0, 310 | }, 311 | "2": map[string]float64{ 312 | "luck": 0.75, "uncleRate": 0.5, "orphanRate": 0, 313 | }, 314 | "4": map[string]float64{ 315 | "luck": 1.125, "uncleRate": 0.5, "orphanRate": 0.25, 316 | }, 317 | } 318 | 319 | if !reflect.DeepEqual(stats, expectedStats) { 320 | t.Error("Stats != expected stats") 321 | } 322 | } 323 | 324 | func reset() { 325 | keys := r.client.Keys(r.prefix + ":*").Val() 326 | for _, k := range keys { 327 | r.client.Del(k) 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "math/big" 5 | "regexp" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/ethereum/go-ethereum/common" 10 | "github.com/ethereum/go-ethereum/common/math" 11 | ) 12 | 13 | var Ether = math.BigPow(10, 18) 14 | var Shannon = math.BigPow(10, 9) 15 | 16 | var pow256 = math.BigPow(2, 256) 17 | var addressPattern = regexp.MustCompile("^0x[0-9a-fA-F]{40}$") 18 | var zeroHash = regexp.MustCompile("^0?x?0+$") 19 | 20 | func IsValidHexAddress(s string) bool { 21 | if IsZeroHash(s) || !addressPattern.MatchString(s) { 22 | return false 23 | } 24 | return true 25 | } 26 | 27 | func IsZeroHash(s string) bool { 28 | return zeroHash.MatchString(s) 29 | } 30 | 31 | func MakeTimestamp() int64 { 32 | return time.Now().UnixNano() / int64(time.Millisecond) 33 | } 34 | 35 | func GetTargetHex(diff int64) string { 36 | difficulty := big.NewInt(diff) 37 | diff1 := new(big.Int).Div(pow256, difficulty) 38 | return string(common.ToHex(diff1.Bytes())) 39 | } 40 | 41 | func TargetHexToDiff(targetHex string) *big.Int { 42 | targetBytes := common.FromHex(targetHex) 43 | return new(big.Int).Div(pow256, new(big.Int).SetBytes(targetBytes)) 44 | } 45 | 46 | func ToHex(n int64) string { 47 | return "0x0" + strconv.FormatInt(n, 16) 48 | } 49 | 50 | func FormatReward(reward *big.Int) string { 51 | return reward.String() 52 | } 53 | 54 | func FormatRatReward(reward *big.Rat) string { 55 | wei := new(big.Rat).SetInt(Ether) 56 | reward = reward.Quo(reward, wei) 57 | return reward.FloatString(8) 58 | } 59 | 60 | func StringInSlice(a string, list []string) bool { 61 | for _, b := range list { 62 | if b == a { 63 | return true 64 | } 65 | } 66 | return false 67 | } 68 | 69 | func MustParseDuration(s string) time.Duration { 70 | value, err := time.ParseDuration(s) 71 | if err != nil { 72 | panic("util: Can't parse duration `" + s + "`: " + err.Error()) 73 | } 74 | return value 75 | } 76 | 77 | func String2Big(num string) *big.Int { 78 | n := new(big.Int) 79 | n.SetString(num, 0) 80 | return n 81 | } 82 | -------------------------------------------------------------------------------- /www/README.md: -------------------------------------------------------------------------------- 1 | # Pool 2 | 3 | This README outlines the details of collaborating on this Ember application. 4 | A short introduction of this app could easily go here. 5 | 6 | ## Prerequisites 7 | 8 | You will need the following things properly installed on your computer. 9 | 10 | * [Git](http://git-scm.com/) 11 | * [Node.js](http://nodejs.org/) (with NPM) 12 | * [Bower](http://bower.io/) 13 | * [Ember CLI](http://www.ember-cli.com/) 14 | * [PhantomJS](http://phantomjs.org/) 15 | 16 | ## Installation 17 | 18 | * `git clone ` this repository 19 | * change into the new directory 20 | * `npm install` 21 | * `bower install` 22 | 23 | ## Running / Development 24 | 25 | * `ember server` 26 | * Visit your app at [http://localhost:4200](http://localhost:4200). 27 | 28 | ### Code Generators 29 | 30 | Make use of the many generators for code, try `ember help generate` for more details 31 | 32 | ### Running Tests 33 | 34 | * `ember test` 35 | * `ember test --server` 36 | 37 | ### Building 38 | 39 | * `ember build` (development) 40 | * `ember build --environment production` (production) 41 | 42 | ### Deploying 43 | 44 | Specify what it takes to deploy your app. 45 | 46 | ## Further Reading / Useful Links 47 | 48 | * [ember.js](http://emberjs.com/) 49 | * [ember-cli](http://www.ember-cli.com/) 50 | * Development Browser Extensions 51 | * [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi) 52 | * [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/) 53 | 54 | -------------------------------------------------------------------------------- /www/app/app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Resolver from './resolver'; 3 | import loadInitializers from 'ember-load-initializers'; 4 | import config from './config/environment'; 5 | 6 | let App; 7 | 8 | Ember.MODEL_FACTORY_INJECTIONS = true; 9 | 10 | App = Ember.Application.extend({ 11 | modulePrefix: config.modulePrefix, 12 | podModulePrefix: config.podModulePrefix, 13 | Resolver 14 | }); 15 | 16 | loadInitializers(App, config.modulePrefix); 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /www/app/components/active-li.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | const { getOwner } = Ember; 4 | 5 | export default Ember.Component.extend({ 6 | tagName: 'li', 7 | classNameBindings: ['isActive:active:inactive'], 8 | 9 | router: function(){ 10 | return getOwner(this).lookup('router:main'); 11 | }.property(), 12 | 13 | isActive: function(){ 14 | var currentWhen = this.get('currentWhen'); 15 | return this.get('router').isActive(currentWhen); 16 | }.property('router.url', 'currentWhen') 17 | }); 18 | -------------------------------------------------------------------------------- /www/app/controllers/account.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Controller.extend({ 4 | applicationController: Ember.inject.controller('application'), 5 | stats: Ember.computed.reads('applicationController.model.stats'), 6 | 7 | roundPercent: Ember.computed('stats', 'model', { 8 | get() { 9 | var percent = this.get('model.roundShares') / this.get('stats.roundShares'); 10 | if (!percent) { 11 | return 0; 12 | } 13 | return percent; 14 | } 15 | }) 16 | }); 17 | -------------------------------------------------------------------------------- /www/app/controllers/application.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import config from '../config/environment'; 3 | 4 | export default Ember.Controller.extend({ 5 | get config() { 6 | return config.APP; 7 | }, 8 | 9 | height: Ember.computed('model.nodes', { 10 | get() { 11 | var node = this.get('bestNode'); 12 | if (node) { 13 | return node.height; 14 | } 15 | return 0; 16 | } 17 | }), 18 | 19 | roundShares: Ember.computed('model.stats', { 20 | get() { 21 | return parseInt(this.get('model.stats.roundShares')); 22 | } 23 | }), 24 | 25 | difficulty: Ember.computed('model.nodes', { 26 | get() { 27 | var node = this.get('bestNode'); 28 | if (node) { 29 | return node.difficulty; 30 | } 31 | return 0; 32 | } 33 | }), 34 | 35 | hashrate: Ember.computed('difficulty', { 36 | get() { 37 | return this.getWithDefault('difficulty', 0) / config.APP.BlockTime; 38 | } 39 | }), 40 | 41 | immatureTotal: Ember.computed('model', { 42 | get() { 43 | return this.getWithDefault('model.immatureTotal', 0) + this.getWithDefault('model.candidatesTotal', 0); 44 | } 45 | }), 46 | 47 | bestNode: Ember.computed('model.nodes', { 48 | get() { 49 | var node = null; 50 | this.get('model.nodes').forEach(function (n) { 51 | if (!node) { 52 | node = n; 53 | } 54 | if (node.height < n.height) { 55 | node = n; 56 | } 57 | }); 58 | return node; 59 | } 60 | }), 61 | 62 | lastBlockFound: Ember.computed('model', { 63 | get() { 64 | return parseInt(this.get('model.lastBlockFound')) || 0; 65 | } 66 | }), 67 | 68 | roundVariance: Ember.computed('model', { 69 | get() { 70 | var percent = this.get('model.stats.roundShares') / this.get('difficulty'); 71 | if (!percent) { 72 | return 0; 73 | } 74 | return percent.toFixed(2); 75 | } 76 | }), 77 | 78 | nextEpoch: Ember.computed('height', { 79 | get() { 80 | var epochOffset = (30000 - (this.getWithDefault('height', 1) % 30000)) * 1000 * this.get('config').BlockTime; 81 | return Date.now() + epochOffset; 82 | } 83 | }) 84 | }); 85 | -------------------------------------------------------------------------------- /www/app/controllers/help.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Controller.extend({ 4 | applicationController: Ember.inject.controller('application'), 5 | config: Ember.computed.reads('applicationController.config') 6 | }); 7 | -------------------------------------------------------------------------------- /www/app/controllers/index.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Controller.extend({ 4 | applicationController: Ember.inject.controller('application'), 5 | stats: Ember.computed.reads('applicationController'), 6 | config: Ember.computed.reads('applicationController.config'), 7 | 8 | cachedLogin: Ember.computed('login', { 9 | get() { 10 | return this.get('login') || Ember.$.cookie('login'); 11 | }, 12 | set(key, value) { 13 | Ember.$.cookie('login', value); 14 | this.set('model.login', value); 15 | return value; 16 | } 17 | }) 18 | }); 19 | -------------------------------------------------------------------------------- /www/app/formats.js: -------------------------------------------------------------------------------- 1 | var hhmmss = { 2 | hour: 'numeric', 3 | minute: 'numeric', 4 | second: 'numeric' 5 | }; 6 | 7 | export default { 8 | time: { 9 | hhmmss: hhmmss 10 | }, 11 | date: { 12 | hhmmss: hhmmss 13 | }, 14 | number: { 15 | EUR: { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 }, 16 | USD: { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 } 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /www/app/helpers/format-balance.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export function formatBalance(value) { 4 | value = value * 0.000000001; 5 | return value.toFixed(8); 6 | } 7 | 8 | export default Ember.Helper.helper(formatBalance); 9 | -------------------------------------------------------------------------------- /www/app/helpers/format-date-locale.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export function formatDateLocale(ts) { 4 | var date = new Date(ts * 1000); 5 | return date.toLocaleString(); 6 | } 7 | 8 | export default Ember.Helper.helper(formatDateLocale); 9 | -------------------------------------------------------------------------------- /www/app/helpers/format-hashrate.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export function formatHashrate(params/*, hash*/) { 4 | var hashrate = params[0]; 5 | var i = 0; 6 | var units = ['H', 'KH', 'MH', 'GH', 'TH', 'PH']; 7 | while (hashrate > 1000) { 8 | hashrate = hashrate / 1000; 9 | i++; 10 | } 11 | return hashrate.toFixed(2) + ' ' + units[i]; 12 | } 13 | 14 | export default Ember.Helper.helper(formatHashrate); 15 | -------------------------------------------------------------------------------- /www/app/helpers/format-tx.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export function formatTx(value) { 4 | return value[0].substring(2, 26) + "..." + value[0].substring(42); 5 | } 6 | 7 | export default Ember.Helper.helper(formatTx); 8 | -------------------------------------------------------------------------------- /www/app/helpers/seconds-to-ms.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export function secondsToMs(value) { 4 | return value * 1000; 5 | } 6 | 7 | export default Ember.Helper.helper(secondsToMs); 8 | -------------------------------------------------------------------------------- /www/app/helpers/string-to-int.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export function stringToInt(value) { 4 | return parseInt(value); 5 | } 6 | 7 | export default Ember.Helper.helper(stringToInt); 8 | -------------------------------------------------------------------------------- /www/app/helpers/with-metric-prefix.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export function withMetricPrefix(params/*, hash*/) { 4 | var n = params[0]; 5 | 6 | if (n < 1000) { 7 | return n; 8 | } 9 | 10 | var i = 0; 11 | var units = ['K', 'M', 'G', 'T', 'P']; 12 | while (n > 1000) { 13 | n = n / 1000; 14 | i++; 15 | } 16 | return n.toFixed(3) + ' ' + units[i - 1]; 17 | } 18 | 19 | export default Ember.Helper.helper(withMetricPrefix); 20 | -------------------------------------------------------------------------------- /www/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Ethereum Mining Pool 8 | 9 | 10 | 11 | {{content-for "head"}} 12 | 13 | 14 | {{content-for "head-footer"}} 15 | 16 | 17 | {{content-for "body"}} 18 | 19 | 20 | 21 | 22 | {{content-for "body-footer"}} 23 | 24 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /www/app/models/block.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | var Block = Ember.Object.extend({ 4 | variance: Ember.computed('difficulty', 'shares', function() { 5 | var percent = this.get('shares') / this.get('difficulty'); 6 | if (!percent) { 7 | return 0; 8 | } 9 | return percent; 10 | }), 11 | 12 | isLucky: Ember.computed('variance', function() { 13 | return this.get('variance') <= 1.0; 14 | }), 15 | 16 | isOk: Ember.computed('orphan', 'uncle', function() { 17 | return !this.get('orphan'); 18 | }), 19 | 20 | formatReward: Ember.computed('reward', function() { 21 | if (!this.get('orphan')) { 22 | var value = parseInt(this.get('reward')) * 0.000000000000000001; 23 | return value.toFixed(6); 24 | } else { 25 | return 0; 26 | } 27 | }) 28 | }); 29 | 30 | export default Block; 31 | -------------------------------------------------------------------------------- /www/app/models/payment.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | var Payment = Ember.Object.extend({ 4 | formatAmount: Ember.computed('amount', function() { 5 | var value = parseInt(this.get('amount')) * 0.000000001; 6 | return value.toFixed(8); 7 | }) 8 | }); 9 | 10 | export default Payment; 11 | -------------------------------------------------------------------------------- /www/app/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from 'ember-resolver'; 2 | 3 | export default Resolver; 4 | -------------------------------------------------------------------------------- /www/app/router.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import config from './config/environment'; 3 | 4 | var Router = Ember.Router.extend({ 5 | location: config.locationType 6 | }); 7 | 8 | Router.map(function() { 9 | this.route('account', { path: '/account/:login' }, function() { 10 | this.route('payouts'); 11 | }); 12 | this.route('not-found'); 13 | 14 | this.route('blocks', function() { 15 | this.route('immature'); 16 | this.route('pending'); 17 | }); 18 | 19 | this.route('help'); 20 | this.route('payments'); 21 | this.route('miners'); 22 | this.route('about'); 23 | }); 24 | 25 | export default Router; 26 | -------------------------------------------------------------------------------- /www/app/routes/account.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import config from '../config/environment'; 3 | 4 | export default Ember.Route.extend({ 5 | model: function(params) { 6 | var url = config.APP.ApiUrl + 'api/accounts/' + params.login; 7 | return Ember.$.getJSON(url).then(function(data) { 8 | data.login = params.login; 9 | return Ember.Object.create(data); 10 | }); 11 | }, 12 | 13 | setupController: function(controller, model) { 14 | this._super(controller, model); 15 | Ember.run.later(this, this.refresh, 5000); 16 | }, 17 | 18 | actions: { 19 | error(error) { 20 | if (error.status === 404) { 21 | return this.transitionTo('not-found'); 22 | } 23 | } 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /www/app/routes/application.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import config from '../config/environment'; 3 | 4 | export default Ember.Route.extend({ 5 | intl: Ember.inject.service(), 6 | 7 | beforeModel() { 8 | this.get('intl').setLocale('en-us'); 9 | }, 10 | 11 | model: function() { 12 | var url = config.APP.ApiUrl + 'api/stats'; 13 | return Ember.$.getJSON(url).then(function(data) { 14 | return Ember.Object.create(data); 15 | }); 16 | }, 17 | 18 | setupController: function(controller, model) { 19 | this._super(controller, model); 20 | Ember.run.later(this, this.refresh, 5000); 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /www/app/routes/blocks.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Block from "../models/block"; 3 | import config from '../config/environment'; 4 | 5 | export default Ember.Route.extend({ 6 | model: function() { 7 | var url = config.APP.ApiUrl + 'api/blocks'; 8 | return Ember.$.getJSON(url).then(function(data) { 9 | if (data.candidates) { 10 | data.candidates = data.candidates.map(function(b) { 11 | return Block.create(b); 12 | }); 13 | } 14 | if (data.immature) { 15 | data.immature = data.immature.map(function(b) { 16 | return Block.create(b); 17 | }); 18 | } 19 | if (data.matured) { 20 | data.matured = data.matured.map(function(b) { 21 | return Block.create(b); 22 | }); 23 | } 24 | return data; 25 | }); 26 | }, 27 | 28 | setupController: function(controller, model) { 29 | this._super(controller, model); 30 | Ember.run.later(this, this.refresh, 5000); 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /www/app/routes/index.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Route.extend({ 4 | actions: { 5 | lookup(login) { 6 | if (!Ember.isEmpty(login)) { 7 | return this.transitionTo('account', login); 8 | } 9 | } 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /www/app/routes/miners.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import config from '../config/environment'; 3 | 4 | export default Ember.Route.extend({ 5 | model: function() { 6 | var url = config.APP.ApiUrl + 'api/miners'; 7 | return Ember.$.getJSON(url).then(function(data) { 8 | if (data.miners) { 9 | // Convert map to array 10 | data.miners = Object.keys(data.miners).map((value) => { 11 | let m = data.miners[value]; 12 | m.login = value; 13 | return m; 14 | }); 15 | // Sort miners by hashrate 16 | data.miners = data.miners.sort((a, b) => { 17 | if (a.hr < b.hr) { 18 | return 1; 19 | } 20 | if (a.hr > b.hr) { 21 | return -1; 22 | } 23 | return 0; 24 | }); 25 | } 26 | return data; 27 | }); 28 | }, 29 | 30 | setupController: function(controller, model) { 31 | this._super(controller, model); 32 | Ember.run.later(this, this.refresh, 5000); 33 | } 34 | }); 35 | -------------------------------------------------------------------------------- /www/app/routes/payments.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Payment from "../models/payment"; 3 | import config from '../config/environment'; 4 | 5 | export default Ember.Route.extend({ 6 | model: function() { 7 | var url = config.APP.ApiUrl + 'api/payments'; 8 | return Ember.$.getJSON(url).then(function(data) { 9 | if (data.payments) { 10 | data.payments = data.payments.map(function(p) { 11 | return Payment.create(p); 12 | }); 13 | } 14 | return data; 15 | }); 16 | }, 17 | 18 | setupController: function(controller, model) { 19 | this._super(controller, model); 20 | Ember.run.later(this, this.refresh, 5000); 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /www/app/styles/app.css: -------------------------------------------------------------------------------- 1 | /* Sticky footer styles 2 | -------------------------------------------------- */ 3 | html { 4 | position: relative; 5 | min-height: 100%; 6 | } 7 | body { 8 | /* Margin bottom by footer height */ 9 | margin-bottom: 60px; 10 | background: url('/bg.png'); 11 | } 12 | .footer { 13 | position: absolute; 14 | bottom: 0; 15 | width: 100%; 16 | /* Set the fixed height of the footer here */ 17 | height: 60px; 18 | background-color: #f5f5f5; 19 | } 20 | 21 | 22 | /* Custom page CSS 23 | -------------------------------------------------- */ 24 | /* Not required for template or sticky footer method. */ 25 | 26 | body { 27 | padding-top: 20px; 28 | padding-bottom: 5px; 29 | } 30 | 31 | body > .container { 32 | padding: 0px 15px 0; 33 | } 34 | .container .text-muted { 35 | margin: 20px 0; 36 | } 37 | 38 | .footer > .container { 39 | padding-right: 15px; 40 | padding-left: 15px; 41 | } 42 | 43 | .jumbotron { 44 | margin: 0; 45 | padding: 40px 0 15px 0; 46 | margin-bottom: 15px; 47 | } 48 | 49 | code { 50 | font-size: 80%; 51 | } 52 | 53 | .navbar-default { 54 | background-color: #04191f; 55 | border-color: #69102b; 56 | } 57 | .navbar-default .navbar-brand { 58 | color: #dadada; 59 | } 60 | .navbar-default .navbar-brand:hover, .navbar-default .navbar-brand:focus { 61 | color: #ffffff; 62 | } 63 | .navbar-default .navbar-text { 64 | color: #dadada; 65 | } 66 | .navbar-default .navbar-nav > li > a { 67 | color: #dadada; 68 | } 69 | .navbar-default .navbar-nav > li > a:hover, .navbar-default .navbar-nav > li > a:focus { 70 | color: #ffffff; 71 | } 72 | .navbar-default .navbar-nav > .active > a, .navbar-default .navbar-nav > .active > a:hover, .navbar-default .navbar-nav > .active > a:focus { 73 | color: #ffffff; 74 | background-color: #0a6c9d; 75 | } 76 | .navbar-default .navbar-nav > .open > a, .navbar-default .navbar-nav > .open > a:hover, .navbar-default .navbar-nav > .open > a:focus { 77 | color: #ffffff; 78 | background-color: #69102b; 79 | } 80 | .navbar-default .navbar-toggle { 81 | border-color: #0a6c9d; 82 | } 83 | .navbar-default .navbar-toggle:hover, .navbar-default .navbar-toggle:focus { 84 | background-color: #0a6c9d; 85 | } 86 | .navbar-default .navbar-toggle .icon-bar { 87 | background-color: #dadada; 88 | } 89 | .navbar-default .navbar-collapse, 90 | .navbar-default .navbar-form { 91 | border-color: #dadada; 92 | } 93 | .navbar-default .navbar-link { 94 | color: #dadada; 95 | } 96 | .navbar-default .navbar-link:hover { 97 | color: #ffffff; 98 | } 99 | 100 | @media (max-width: 767px) { 101 | .navbar-default .navbar-nav .open .dropdown-menu > li > a { 102 | color: #dadada; 103 | } 104 | .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover, .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus { 105 | color: #ffffff; 106 | } 107 | .navbar-default .navbar-nav .open .dropdown-menu > .active > a, .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover, .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus { 108 | color: #ffffff; 109 | background-color: #0a6c9d; 110 | } 111 | } 112 | 113 | span.logo-1 { 114 | font-weight: 700; 115 | color: #1994b8; 116 | } 117 | 118 | span.logo-2 { 119 | font-weight: 300; 120 | color: #FFF; 121 | } 122 | 123 | span.logo-3 { 124 | color: #FFF; 125 | font-weight: 100; 126 | } 127 | 128 | .navbar-collapse { 129 | font-size: 14px; 130 | font-weight: 200; 131 | } 132 | 133 | .note { 134 | margin: 0 0 20px 0; 135 | padding: 15px 30px 15px 15px; 136 | border-left: 5px solid #eee; 137 | border-radius: 5px; 138 | } 139 | 140 | .note-info { 141 | background-color: #E8F6FC; 142 | border-color: #57b5e3; 143 | } 144 | 145 | .note-danger { 146 | background-color: #ff9999; 147 | border-color: #ff0000; 148 | } 149 | 150 | h4.note { 151 | margin-top: 0; 152 | font-weight: 300 !important; 153 | } 154 | 155 | .hash { 156 | font-family: 'Inconsolata', monospace; 157 | } 158 | 159 | /* Stats */ 160 | .stats { 161 | margin-bottom: 10px; 162 | margin-top: 5px; 163 | } 164 | .stats:last-child{ 165 | width: auto; 166 | } 167 | .stats > h3 > i { 168 | width: 21px; 169 | } 170 | .stats > div{ 171 | padding: 5px 0; 172 | } 173 | .stats > div > .fa { 174 | width: 25px; 175 | } 176 | .stats > div > span:first-of-type{ 177 | font-weight: bold; 178 | } 179 | -------------------------------------------------------------------------------- /www/app/templates/about.hbs: -------------------------------------------------------------------------------- 1 |
2 | 5 |

Terms of Service

6 |

By using the pool you accept all possible risks related to experimental software usage.
7 | Pool owner can't compensate any irreversible losses, but will do his best to prevent worst case. 8 |

9 |

Details

10 |

11 |

19 |

20 |
21 | -------------------------------------------------------------------------------- /www/app/templates/account.hbs: -------------------------------------------------------------------------------- 1 | {{outlet 'error' }} 2 |
3 |
4 |
5 |
6 |
7 | Immature Balance: {{format-balance model.stats.immature}}
8 | Preliminary balance awaiting blocks to mature. 9 |
10 |
11 | Pending Balance: {{format-balance model.stats.balance}}
12 | Credited coins awaiting payout. 13 |
14 | {{#if model.stats.pending}} 15 |
16 | Current Payment: {{format-balance model.stats.pending}}
17 |
18 | {{/if}} 19 |
Total Paid: {{format-balance model.stats.paid}}
20 |
21 |
22 | {{#if model.stats.lastShare}} 23 |
24 | Last Share Submitted: {{format-relative (seconds-to-ms (string-to-int model.stats.lastShare))}} 25 |
26 | {{/if}} 27 |
Workers Online: {{format-number model.workersOnline}}
28 |
Hashrate (30m): {{format-hashrate model.currentHashrate}}
29 |
Hashrate (3h): {{format-hashrate model.hashrate}}
30 |
31 |
32 |
Blocks Found: {{format-number model.stats.blocksFound fallback='0'}}
33 |
Total Payments: {{format-number model.paymentsTotal}}
34 |
35 | Your Round Share: {{format-number roundPercent style='percent' maximumFractionDigits='6'}}
36 | Percent of your contribution to current round. 37 |
38 |
39 | 40 | Epoch Switch: {{format-relative applicationController.nextEpoch units="hour"}} 41 |
42 |
43 |
44 |
45 |
46 | 47 |
48 | 56 |
57 | 58 | {{outlet}} 59 | -------------------------------------------------------------------------------- /www/app/templates/account/index.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#if model.workers}} 3 |

Your Workers

4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {{#each-in model.workers as |k v|}} 16 | 17 | 18 | 19 | 20 | 21 | 22 | {{/each-in}} 23 | 24 |
IDHashrate (rough, short average)Hashrate (accurate, long average)Last Share
{{k}}{{format-hashrate v.hr}}{{format-hashrate v.hr2}}{{format-relative (seconds-to-ms v.lastBeat)}}
25 |
26 | {{else}} 27 |

No workers online

28 | {{/if}} 29 | 36 | 39 |
40 | -------------------------------------------------------------------------------- /www/app/templates/account/payouts.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#if model.payments}} 3 |

Your Latest Payouts

4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{#each model.payments as |tx|}} 15 | 16 | 17 | 20 | 21 | 22 | {{/each}} 23 | 24 |
TimeTx IDAmount
{{format-date-locale tx.timestamp}} 18 | {{tx.tx}} 19 | {{format-balance tx.amount}}
25 |
26 | {{else}} 27 |

No payouts yet

28 | {{/if}} 29 |
30 | -------------------------------------------------------------------------------- /www/app/templates/application-error.hbs: -------------------------------------------------------------------------------- 1 |
2 | 6 |
7 | -------------------------------------------------------------------------------- /www/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 | 2 | 52 | 53 | {{outlet}} 54 | -------------------------------------------------------------------------------- /www/app/templates/blocks.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Pool always pay full block reward including TX fees and uncle rewards.

4 | 5 | Block maturity requires up to 520 blocks. 6 | Usually it's less indeed. 7 | 8 |
9 |
10 |
11 | {{#if model.luck}} 12 | {{partial "luck"}} 13 | {{/if}} 14 | 25 | {{outlet}} 26 |
27 | -------------------------------------------------------------------------------- /www/app/templates/blocks/block.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{#if block.uncle}} 4 | {{format-number block.height}} 5 | {{else}} 6 | {{format-number block.height}} 7 | {{/if}} 8 | 9 | 10 | {{#if block.uncle}} 11 | {{block.hash}} 12 | {{else if block.orphan}} 13 | Orphan 14 | {{else}} 15 | {{block.hash}} 16 | {{/if}} 17 | 18 | {{format-date-locale block.timestamp}} 19 | 20 | {{#if block.isLucky}} 21 | {{format-number block.variance style='percent'}} 22 | {{else}} 23 | {{format-number block.variance style='percent'}} 24 | {{/if}} 25 | 26 | 27 | {{#if block.uncle}} 28 | {{block.formatReward}} 29 | {{else if block.isOk}} 30 | {{block.formatReward}} 31 | {{/if}} 32 | 33 | 34 | -------------------------------------------------------------------------------- /www/app/templates/blocks/immature.hbs: -------------------------------------------------------------------------------- 1 | {{#if model.immature}} 2 |

Immature Blocks

3 | 4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {{#each model.immature as |block|}} 17 | {{partial "blocks/block"}} 18 | {{/each}} 19 | 20 |
HeightBlock HashTime FoundVarianceReward
21 |
22 | {{else}} 23 |

No immature blocks yet

24 | {{/if}} 25 | -------------------------------------------------------------------------------- /www/app/templates/blocks/index.hbs: -------------------------------------------------------------------------------- 1 | {{#if model.matured}} 2 |

Matured Blocks

3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {{#each model.matured as |block|}} 16 | {{partial "blocks/block"}} 17 | {{/each}} 18 | 19 |
HeightBlock HashTime FoundVarianceReward
20 |
21 | {{else}} 22 |

No matured blocks yet

23 | {{/if}} 24 | -------------------------------------------------------------------------------- /www/app/templates/blocks/pending.hbs: -------------------------------------------------------------------------------- 1 | {{#if model.candidates}} 2 |

Recently Found Blocks

3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {{#each model.candidates as |block|}} 14 | 15 | 16 | 17 | 24 | 25 | {{/each}} 26 | 27 |
HeightTime FoundVariance
{{format-number block.height}}{{format-date-locale block.timestamp}} 18 | {{#if block.isLucky}} 19 | {{format-number block.variance style='percent'}} 20 | {{else}} 21 | {{format-number block.variance style='percent'}} 22 | {{/if}} 23 |
28 |
29 | {{else}} 30 |

No new blocks yet

31 | {{/if}} 32 | -------------------------------------------------------------------------------- /www/app/templates/components/active-li.hbs: -------------------------------------------------------------------------------- 1 | {{yield}} 2 | -------------------------------------------------------------------------------- /www/app/templates/help.hbs: -------------------------------------------------------------------------------- 1 |
2 | 5 |

In order to mine on this pool you need to have an 6 | ethminer installation 7 | pointed to
{{config.HttpHost}}:{{config.HttpPort}}/YOUR_ETH_ADDRESS/RIG_ID 8 |

9 |
10 |
YOUR_ETH_ADDRESS
11 |
This is your address for payouts, generate one with geth, or mine directly to exchange like 12 | Poloniex 13 | or Bittrex.
14 | Example: 0xb85150eb365e7df0941f0cf08235f987ba91506a. 15 |
16 |
RIG_ID
17 |
18 | ID of your farm to distinguish it from your other rig. If you have just one rig, feel free to omit this param. 19 | This param must be short alphanumeric string with optional dashes and underscores.
20 | Example: rig-1 21 |
22 |
23 |

24 | Full example: 25 | ethminer -F {{config.HttpHost}}:{{config.HttpPort}}/0xb85150eb365e7df0941f0cf08235f987ba91506a/myfarm -G --farm-recheck 200.
26 | Hint: If you are compiling ethminer from latest source, please also use 27 | extra --disable-submit-hashrate option. 28 |

29 | 30 |

Stratum Mining with Stratum Proxy for Ethereum

31 |

Grab proxy from eth-proxy GitHub repo.

32 |

Edit eth-proxy.conf and specify our pool's HOST: {{config.StratumHost}}, PORT: {{config.StratumPort}} and your WALLET.

33 | 34 |

Mining with Ether-Proxy

35 |

Use stable release of Ethereum Solo/Pool Mining Proxy.

36 | 37 |

Advice

38 |

CPU mining is not recommended.

39 |

Terms of Service

40 |

By using the pool you accept all possible risks related to experimental software usage.
41 | Pool owner can't compensate any irreversible losses, but will do his best to prevent worst case. 42 |

43 |
44 | -------------------------------------------------------------------------------- /www/app/templates/index.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |

6 | Open Ethereum Pool 7 |

8 | Min. payout threshold: {{config.PayoutThreshold}}, Payouts run twice per day.
9 | PROP Stable and profitable pool with regular payouts. 10 |
11 |
12 |
Miners Online: {{format-number stats.model.minersTotal}}
13 |
Pool Hash Rate: {{format-hashrate stats.model.hashrate}}
14 |
Pool Fee: {{config.PoolFee}}
15 | {{#if stats.model.stats.lastBlockFound}} 16 |
Last Block Found: {{format-relative (seconds-to-ms stats.model.stats.lastBlockFound)}}
17 | {{/if}} 18 |
19 |
20 |
Network Difficulty: {{with-metric-prefix stats.difficulty}}
21 |
Network Hash Rate: {{format-hashrate stats.hashrate}}
22 |
Blockchain Height: {{format-number stats.height}}
23 |
Current Round Variance: {{format-number stats.roundVariance style='percent'}}
24 |
25 |
26 |
27 |
28 | 29 |
30 |
31 |

Your Stats & Payment History

32 |
33 | {{input value=cachedLogin class="form-control" placeholder="Enter Your Ethereum Address"}} 34 | 35 | 38 | 39 |
40 |
41 |
42 |
43 |

44 | ethminer.exe -F {{config.HttpHost}}:{{config.HttpPort}}/<address>/<worker> -G 45 |

46 |
47 |
48 | -------------------------------------------------------------------------------- /www/app/templates/luck.hbs: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {{#each-in model.luck as |total row|}} 13 | 14 | 15 | 16 | 17 | 18 | 19 | {{/each-in}} 20 | 21 |
BlocksShares/DiffUncle RateOrphan Rate
{{total}}{{format-number row.luck style='percent'}}{{format-number row.uncleRate style='percent'}}{{format-number row.orphanRate style='percent'}}
22 |
23 | -------------------------------------------------------------------------------- /www/app/templates/miners.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Total hashrate: {{format-hashrate model.hashrate}}.

4 | Total miners: {{model.minersTotal}} 5 |
6 |
7 |
8 | {{#if model.miners}} 9 |

Miners

10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {{#each model.miners as |m|}} 21 | 22 | 23 | 24 | 25 | 26 | {{/each}} 27 | 28 |
LoginHashrateLast Beat
{{#link-to 'account' m.login class='hash'}}{{m.login}}{{/link-to}}{{format-hashrate m.hr}}{{format-date-locale m.lastBeat}}
29 |
30 | {{else}} 31 |

No miners

32 | {{/if}} 33 |
34 | -------------------------------------------------------------------------------- /www/app/templates/not-found.hbs: -------------------------------------------------------------------------------- 1 |
2 | 6 |
7 | -------------------------------------------------------------------------------- /www/app/templates/payments.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Pool always pay tx fees from it's own pocket for now.

4 | Total payments sent: {{model.paymentsTotal}} 5 |
6 |
7 |
8 | {{#if model.payments}} 9 |

Latest Payouts

10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {{#each model.payments as |tx|}} 22 | 23 | 24 | 25 | 28 | 31 | 32 | {{/each}} 33 | 34 |
TimeAmountAddressTx ID
{{format-date-locale tx.timestamp}}{{format-number tx.formatAmount}} 26 | {{tx.address}} 27 | 29 | {{format-tx tx.tx}} 30 |
35 |
36 | {{else}} 37 |

No payouts yet

38 | {{/if}} 39 |
40 | -------------------------------------------------------------------------------- /www/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pool", 3 | "dependencies": { 4 | "ember": "lts-2-8", 5 | "ember-cli-shims": "0.1.3" 6 | }, 7 | "devDependencies": { 8 | "bootstrap": "~3.3.7", 9 | "font-awesome": "fontawesome#~4.7.0" 10 | }, 11 | "resolutions": { 12 | "ember": "lts-2-8" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /www/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ./node_modules/.bin/ember build --environment production 4 | -------------------------------------------------------------------------------- /www/config/ember-intl.js: -------------------------------------------------------------------------------- 1 | /*jshint node:true*/ 2 | 3 | module.exports = function(environment) { 4 | return { 5 | /** 6 | * The locales that are application supports. 7 | * 8 | * This is optional and is automatically set if project stores translations 9 | * where ember-intl is able to look them up (/translations/). 10 | * 11 | * If the project relies on side-loading translations, then you must explicitly 12 | * list out the locales. i.e: ['en-us', 'en-gb', 'fr-fr'] 13 | * 14 | * @property locales 15 | * @type {Array?} 16 | * @default "null" 17 | */ 18 | locales: null, 19 | 20 | /** 21 | * baseLocale is used to determine if translation keys are missing from other locales. 22 | * This is property is optional, and if you rely on sideloading translations then 23 | * this should be null 24 | * 25 | * @property baseLocale 26 | * @type {String?} 27 | * @default "null" 28 | */ 29 | baseLocale: null, 30 | 31 | /** 32 | * disablePolyfill prevents the polyfill from being bundled in the asset folder of the build 33 | * 34 | * @property disablePolyfill 35 | * @type {Boolean} 36 | * @default "false" 37 | */ 38 | disablePolyfill: false, 39 | 40 | /** 41 | * prevents the translations from being bundled with the application code. 42 | * This enables asynchronously loading the translations for the active locale 43 | * by fetching them from the asset folder of the build. 44 | * 45 | * See: https://github.com/jasonmit/ember-intl/wiki/Asynchronously-loading-translations 46 | * 47 | * @property publicOnly 48 | * @type {Boolean} 49 | * @default "false" 50 | */ 51 | publicOnly: false 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /www/config/environment.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | 3 | module.exports = function(environment) { 4 | var ENV = { 5 | modulePrefix: 'open-ethereum-pool', 6 | environment: environment, 7 | rootURL: '/', 8 | locationType: 'hash', 9 | EmberENV: { 10 | FEATURES: { 11 | // Here you can enable experimental features on an ember canary build 12 | // e.g. 'with-controller': true 13 | } 14 | }, 15 | 16 | APP: { 17 | // API host and port 18 | ApiUrl: '//example.net/', 19 | 20 | // HTTP mining endpoint 21 | HttpHost: 'http://example.net', 22 | HttpPort: 8888, 23 | 24 | // Stratum mining endpoint 25 | StratumHost: 'example.net', 26 | StratumPort: 8008, 27 | 28 | // Fee and payout details 29 | PoolFee: '1%', 30 | PayoutThreshold: '0.5 Ether', 31 | 32 | // For network hashrate (change for your favourite fork) 33 | BlockTime: 14.4 34 | } 35 | }; 36 | 37 | if (environment === 'development') { 38 | /* Override ApiUrl just for development, while you are customizing 39 | frontend markup and css theme on your workstation. 40 | */ 41 | ENV.APP.ApiUrl = 'http://localhost:8080/' 42 | // ENV.APP.LOG_RESOLVER = true; 43 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 44 | // ENV.APP.LOG_TRANSITIONS = true; 45 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 46 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 47 | } 48 | 49 | if (environment === 'test') { 50 | // Testem prefers this... 51 | ENV.locationType = 'none'; 52 | 53 | // keep test console output quieter 54 | ENV.APP.LOG_ACTIVE_GENERATION = false; 55 | ENV.APP.LOG_VIEW_LOOKUPS = false; 56 | 57 | ENV.APP.rootElement = '#ember-testing'; 58 | } 59 | 60 | if (environment === 'production') { 61 | 62 | } 63 | 64 | return ENV; 65 | }; 66 | -------------------------------------------------------------------------------- /www/ember-cli-build.js: -------------------------------------------------------------------------------- 1 | /* global require, module */ 2 | var EmberApp = require('ember-cli/lib/broccoli/ember-app'); 3 | var Funnel = require('broccoli-funnel'); 4 | 5 | module.exports = function(defaults) { 6 | var app = new EmberApp(defaults, { 7 | // Add options here 8 | SRI: { 9 | enabled: false 10 | } 11 | }); 12 | 13 | // Use `app.import` to add additional libraries to the generated 14 | // output files. 15 | // 16 | // If you need to use different assets in different 17 | // environments, specify an object as the first parameter. That 18 | // object's keys should be the environment name and the values 19 | // should be the asset to use in that environment. 20 | // 21 | // If the library that you are including contains AMD or ES6 22 | // modules that you would like to import into your application 23 | // please specify an object with the list of modules as keys 24 | // along with the exports of each module as its value. 25 | app.import('bower_components/bootstrap/dist/css/bootstrap.min.css') 26 | app.import('bower_components/bootstrap/dist/js/bootstrap.min.js'); 27 | app.import('bower_components/font-awesome/css/font-awesome.min.css') 28 | 29 | var extraAssets = new Funnel('bower_components/font-awesome/fonts', { 30 | srcDir: '/', 31 | include: ['**/*.ttf', '**/*.woff', '**/*.woff2'], 32 | destDir: '/fonts' 33 | }); 34 | 35 | return app.toTree(extraAssets); 36 | }; 37 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-ethereum-pool", 3 | "version": "0.0.0", 4 | "description": "Open Ethereum Pool", 5 | "private": true, 6 | "directories": { 7 | "doc": "doc", 8 | "test": "tests" 9 | }, 10 | "scripts": { 11 | "build": "ember build", 12 | "start": "ember server", 13 | "test": "ember test" 14 | }, 15 | "repository": "", 16 | "engines": { 17 | "node": ">= 0.12.0" 18 | }, 19 | "author": "", 20 | "license": "MIT", 21 | "devDependencies": { 22 | "broccoli-asset-rev": "^2.4.5", 23 | "broccoli-funnel": "^1.0.9", 24 | "ember-ajax": "^2.4.1", 25 | "ember-cli": "2.9.1", 26 | "ember-cli-app-version": "^2.0.0", 27 | "ember-cli-babel": "^5.1.7", 28 | "ember-cli-dependency-checker": "^1.3.0", 29 | "ember-cli-htmlbars": "^1.0.10", 30 | "ember-cli-htmlbars-inline-precompile": "^0.3.3", 31 | "ember-cli-inject-live-reload": "^1.4.1", 32 | "ember-cli-jshint": "^1.0.4", 33 | "ember-cli-qunit": "^3.0.1", 34 | "ember-cli-release": "^0.2.9", 35 | "ember-cli-sri": "^2.1.0", 36 | "ember-cli-test-loader": "^1.1.0", 37 | "ember-cli-uglify": "^1.2.0", 38 | "ember-export-application-global": "^1.0.5", 39 | "ember-load-initializers": "^0.5.1", 40 | "ember-resolver": "^2.0.3", 41 | "ember-welcome-page": "^1.0.3", 42 | "loader.js": "^4.0.10", 43 | "ember-intl": "2.15.1", 44 | "ember-cli-cookie": "^0.2.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /www/public/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenEthMine/open-ethereum-pool/ef123ea0de32bf65f8b48522f57e6988a42aa14d/www/public/bg.png -------------------------------------------------------------------------------- /www/public/crossdomain.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /www/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenEthMine/open-ethereum-pool/ef123ea0de32bf65f8b48522f57e6988a42aa14d/www/public/favicon.ico -------------------------------------------------------------------------------- /www/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /www/testem.json: -------------------------------------------------------------------------------- 1 | { 2 | "framework": "qunit", 3 | "test_page": "tests/index.html?hidepassed", 4 | "disable_watching": true, 5 | "launch_in_ci": [ 6 | "PhantomJS" 7 | ], 8 | "launch_in_dev": [ 9 | "PhantomJS", 10 | "Chrome" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /www/tests/helpers/destroy-app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default function destroyApp(application) { 4 | Ember.run(application, 'destroy'); 5 | } 6 | -------------------------------------------------------------------------------- /www/tests/helpers/module-for-acceptance.js: -------------------------------------------------------------------------------- 1 | import { module } from 'qunit'; 2 | import Ember from 'ember'; 3 | import startApp from '../helpers/start-app'; 4 | import destroyApp from '../helpers/destroy-app'; 5 | 6 | const { RSVP: { Promise } } = Ember; 7 | 8 | export default function(name, options = {}) { 9 | module(name, { 10 | beforeEach() { 11 | this.application = startApp(); 12 | 13 | if (options.beforeEach) { 14 | return options.beforeEach.apply(this, arguments); 15 | } 16 | }, 17 | 18 | afterEach() { 19 | let afterEach = options.afterEach && options.afterEach.apply(this, arguments); 20 | return Promise.resolve(afterEach).then(() => destroyApp(this.application)); 21 | } 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /www/tests/helpers/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from '../../resolver'; 2 | import config from '../../config/environment'; 3 | 4 | const resolver = Resolver.create(); 5 | 6 | resolver.namespace = { 7 | modulePrefix: config.modulePrefix, 8 | podModulePrefix: config.podModulePrefix 9 | }; 10 | 11 | export default resolver; 12 | -------------------------------------------------------------------------------- /www/tests/helpers/start-app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Application from '../../app'; 3 | import config from '../../config/environment'; 4 | 5 | export default function startApp(attrs) { 6 | let application; 7 | 8 | let attributes = Ember.merge({}, config.APP); 9 | attributes = Ember.merge(attributes, attrs); // use defaults, but you can override; 10 | 11 | Ember.run(() => { 12 | application = Application.create(attributes); 13 | application.setupForTesting(); 14 | application.injectTestHelpers(); 15 | }); 16 | 17 | return application; 18 | } 19 | -------------------------------------------------------------------------------- /www/tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Pool Tests 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | {{content-for "test-head"}} 12 | 13 | 14 | 15 | 16 | 17 | {{content-for "head-footer"}} 18 | {{content-for "test-head-footer"}} 19 | 20 | 21 | {{content-for "body"}} 22 | {{content-for "test-body"}} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {{content-for "body-footer"}} 31 | {{content-for "test-body-footer"}} 32 | 33 | 34 | -------------------------------------------------------------------------------- /www/tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import resolver from './helpers/resolver'; 2 | import { 3 | setResolver 4 | } from 'ember-qunit'; 5 | 6 | setResolver(resolver); 7 | -------------------------------------------------------------------------------- /www/translations/en-us.yaml: -------------------------------------------------------------------------------- 1 | product: 2 | info: '{product} will cost {price, number, USD} if ordered by {deadline, date, time}' 3 | title: 'Hello world!' 4 | html: 5 | info: '{product} will cost {price, number, USD} if ordered by {deadline, date, time}' 6 | --------------------------------------------------------------------------------