diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..487e7a2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+bin
+flexassistant.yaml
+flexassistant.db
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b1cc0df
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,17 @@
+APPVERSION := $(shell cat ./VERSION)
+GOVERSION := $(shell go version | awk '{print $$3}')
+GITCOMMIT := $(shell git log -1 --oneline | awk '{print $$1}')
+LDFLAGS = -X main.AppVersion=${APPVERSION} -X main.GoVersion=${GOVERSION} -X main.GitCommit=${GITCOMMIT}
+PLATFORM := $(shell uname -s)
+ARCH := $(shell uname -m)
+
+.PHONY: clean
+
+build:
+ go build -ldflags "${LDFLAGS}" -o bin/flexassistant *.go
+
+release:
+ go build -ldflags "${LDFLAGS}" -o bin/flexassistant-${APPVERSION}-${PLATFORM}-${ARCH} *.go
+
+clean:
+ rm -rf bin
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a445c5d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,101 @@
+# flexassistant
+
+[Flexpool.io](https://www.flexpool.io/) is a famous cryptocurrency mining or farming pool supporting
+[Ethereum](https://ethereum.org/en/) and [Chia](https://www.chia.net/) blockchains. As a miner, or a farmer, we like to
+get **notified** when a **block** is mined, or farmed. We also like to keep track of our **unpaid balance** and our
+**transactions** to our personal wallet.
+
+*flexassistant* is a tool that parses the Flexpool API and sends notifications via [Telegram](https://telegram.org/).
+
+
+
+
+
+
+## Installation
+
+*Note: this guide has been written with Linux x86_64 in mind.*
+
+### Binaries
+
+Go to [Releases](/releases) to download the binary in the version you like (latest is recommended).
+
+Then extract the tarball:
+
+```
+tar xvpzf flexassistant-VERSION-Linux-x86_64.tgz
+```
+
+Write checksum information to a local file:
+
+```
+echo checksum > flexassistant-VERSION-Linux-x86_64.sha256sum
+```
+
+Verify checksums to avoid binary corruption:
+
+```
+sha256sum -c flexassistant-VERSION-Linux-x86_64.sha256sum
+```
+
+### Compilation
+
+You will need to install [Go](https://golang.org/dl/), [Git](https://git-scm.com/) and a development toolkit (including [make](https://linux.die.net/man/1/make)) for your environment.
+
+Then, you'll need to download and compile the source code:
+
+```
+git clone https://github.com/jouir/flexassistant.git
+cd flexassistant
+make
+```
+
+The binary will be available under the `bin` directory:
+
+```
+ls -l bin/flexassistant
+```
+
+## Configuration
+
+*flexassistant* can be configured using a YaML file. By default, the `flexassistant.yaml` file is used but it can be another file provided by the `-config` argument.
+
+As a good start, you can copy the configuration file example:
+
+```
+cp -p flexassistant.yaml.example flexassistant.yaml
+```
+
+Then edit this file at will.
+
+Reference:
+* `database-file` (optional): file name of the database file to persist information between two executions (SQLite database)
+* `max-blocks` (optional): maximum number of blocks to retreive from the API
+* `max-payments` (optional): maximum number of payments to retreive from the API
+* `pools` (optional): list of pools
+ * `coin`: coin of the pool (ex: `eth`, `xch`)
+ * `enable-blocks` (optional): enable block notifications for this pool (disabled by default)
+* `miners` (optional): list of miners and/or farmers
+ * `address`: address of the miner or the farmer registered on the API
+ * `enable-balance` (optional): enable balance notifications (disabled by default)
+ * `enable-payments` (optional): enable payments notifications (disabled by default)
+* `telegram`: Telegram configuration
+ * `token`: token of the Telegram bot
+ * `chat-id` (optional if `channel-name` is present): chat identifier to send Telegram notifications
+ * `channel-name` (optional if `chat-id` is present): channel name to send Telegram notifications
+
+## Usage
+
+```
+Usage of ./flexassistant:
+ -config string
+ Configuration file name (default "flexassistant.yaml")
+ -debug
+ Print even more logs
+ -quiet
+ Log errors only
+ -verbose
+ Print more logs
+ -version
+ Print version and exit
+```
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..9f8e9b6
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.0
\ No newline at end of file
diff --git a/client.go b/client.go
new file mode 100644
index 0000000..674b8f4
--- /dev/null
+++ b/client.go
@@ -0,0 +1,135 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "sort"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// FlexpoolAPIURL constant to store Flexpool API URL
+const FlexpoolAPIURL = "https://api.flexpool.io/v2"
+
+// MaxIterations to avoid infinite loop while requesting paged routes on Flexpool API
+const MaxIterations = 10
+
+// UserAgent to identify ourselves on the Flexpool API
+var UserAgent = fmt.Sprintf("flexassistant/%s", AppVersion)
+
+// FlexpoolClient to store the HTTP client
+type FlexpoolClient struct {
+ client *http.Client
+}
+
+// NewFlexpoolClient to create a client to manage Flexpool API calls
+func NewFlexpoolClient() *FlexpoolClient {
+ return &FlexpoolClient{
+ client: &http.Client{Timeout: time.Second * 3},
+ }
+}
+
+// request to create an HTTPS request, call the Flexpool API, detect errors and return the result
+func (f *FlexpoolClient) request(url string) (result map[string]interface{}, err error) {
+ log.Debugf("Requesting %s", url)
+
+ request, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ request.Header.Set("User-Agent", UserAgent)
+
+ resp, err := f.client.Do(request)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ jsonBody, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ json.Unmarshal(jsonBody, &result)
+
+ if result["error"] == nil {
+ return result["result"].(map[string]interface{}), nil
+ }
+ return nil, fmt.Errorf("Flexpool API error: %s", result["error"].(string))
+}
+
+// MinerBalance returns the current unpaid balance
+func (f *FlexpoolClient) MinerBalance(coin string, address string) (float64, error) {
+ response, err := f.request(fmt.Sprintf("%s/miner/balance?coin=%s&address=%s", FlexpoolAPIURL, coin, address))
+ if err != nil {
+ return 0, err
+ }
+ return response["balance"].(float64), nil
+}
+
+// MinerPayments returns an ordered list of payments
+func (f *FlexpoolClient) MinerPayments(coin string, address string, limit int) (payments []*Payment, err error) {
+ page := 0
+ for {
+ response, err := f.request(fmt.Sprintf("%s/miner/payments/?coin=%s&address=%s&page=%d", FlexpoolAPIURL, coin, address, page))
+ if err != nil {
+ return nil, err
+ }
+
+ for _, result := range response["data"].([]interface{}) {
+ raw := result.(map[string]interface{})
+ payment := NewPayment(
+ raw["hash"].(string),
+ raw["value"].(float64),
+ raw["timestamp"].(float64),
+ )
+ payments = append(payments, payment)
+ if len(payments) >= limit {
+ // sort by timestamp
+ sort.Slice(payments, func(p1, p2 int) bool {
+ return payments[p1].Timestamp > payments[p2].Timestamp
+ })
+ return payments, nil
+ }
+ }
+ page = page + 1
+ if page > MaxIterations {
+ return nil, fmt.Errorf("Max iterations of %d reached", MaxIterations)
+ }
+ }
+}
+
+// PoolBlocks returns an ordered list of blocks
+func (f *FlexpoolClient) PoolBlocks(coin string, limit int) (blocks []*Block, err error) {
+ page := 0
+ for {
+ response, err := f.request(fmt.Sprintf("%s/pool/blocks/?coin=%s&page=%d", FlexpoolAPIURL, coin, page))
+ if err != nil {
+ return nil, err
+ }
+
+ for _, result := range response["data"].([]interface{}) {
+ raw := result.(map[string]interface{})
+ block := NewBlock(
+ raw["hash"].(string),
+ raw["number"].(float64),
+ raw["reward"].(float64),
+ )
+ blocks = append(blocks, block)
+ if len(blocks) >= limit {
+ // sort by number
+ sort.Slice(blocks, func(b1, b2 int) bool {
+ return blocks[b1].Number < blocks[b2].Number
+ })
+ return blocks, nil
+ }
+ }
+ page = page + 1
+ if page > MaxIterations {
+ return nil, fmt.Errorf("Max iterations of %d reached", MaxIterations)
+ }
+ }
+}
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..553ed33
--- /dev/null
+++ b/config.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+ "io/ioutil"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Config to receive settings from the configuration file
+type Config struct {
+ DatabaseFile string `yaml:"database-file"`
+ MaxBlocks int `yaml:"max-blocks"`
+ MaxPayments int `yaml:"max-payments"`
+ Pools []PoolConfig `yaml:"pools"`
+ Miners []MinerConfig `yaml:"miners"`
+ TelegramConfig TelegramConfig `yaml:"telegram"`
+}
+
+// PoolConfig to store Pool configuration
+type PoolConfig struct {
+ Coin string `yaml:"coin"`
+ EnableBlocks bool `yaml:"enable-blocks"`
+}
+
+// MinerConfig to store Miner configuration
+type MinerConfig struct {
+ Address string `yaml:"address"`
+ EnableBalance bool `yaml:"enable-balance"`
+ EnablePayments bool `yaml:"enable-payments"`
+}
+
+// TelegramConfig to store Telegram configuration
+type TelegramConfig struct {
+ Token string `yaml:"token"`
+ ChatID int64 `yaml:"chat-id"`
+ ChannelName string `yaml:"channel-name"`
+}
+
+// NewConfig creates a Config with default values
+func NewConfig() *Config {
+ return &Config{
+ DatabaseFile: AppName + ".db",
+ }
+}
+
+// ReadFile reads and parses a YAML configuration file to override default values
+func (c *Config) ReadFile(filename string) (err error) {
+ yamlFile, err := ioutil.ReadFile(filename)
+ if err != nil {
+ return err
+ }
+
+ err = yaml.Unmarshal(yamlFile, &c)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/db.go b/db.go
new file mode 100644
index 0000000..ada9ce8
--- /dev/null
+++ b/db.go
@@ -0,0 +1,22 @@
+package main
+
+import (
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+// NewDatabase creates a SQLite database object from a file name
+func NewDatabase(filename string) (*gorm.DB, error) {
+ return gorm.Open(sqlite.Open(filename), &gorm.Config{})
+}
+
+// CreateDatabaseObjects creates database relations
+func CreateDatabaseObjects(db *gorm.DB) error {
+ if err := db.AutoMigrate(&Miner{}); err != nil {
+ return err
+ }
+ if err := db.AutoMigrate(&Pool{}); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/flexassistant.yaml.example b/flexassistant.yaml.example
new file mode 100644
index 0000000..7320ac4
--- /dev/null
+++ b/flexassistant.yaml.example
@@ -0,0 +1,20 @@
+---
+database-file: flexassistant.db
+max-blocks: 10
+max-payments: 5
+miners:
+ - address: 0x0000000000000000000000000000000000000000
+ enable-balance: true
+ enable-payments: true
+ - address: xch00000000000000000000000000000000000000000000000000000000000
+ enable-balance: true
+ enable-payments: true
+pools:
+ - coin: eth
+ enable-blocks: true
+ - coin: xch
+ enable-blocks: true
+telegram:
+ chat-id: 000000000
+ channel-name: MyTelegramChannel
+ token: 0000000000000000000000000000000000000000000000
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..5aba9cd
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,12 @@
+module github.com/jouir/flexassistant
+
+go 1.16
+
+require (
+ github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.0.0-rc1
+ github.com/leekchan/accounting v1.0.0 // indirect
+ github.com/sirupsen/logrus v1.8.1
+ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
+ gorm.io/driver/sqlite v1.1.5
+ gorm.io/gorm v1.21.15
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e520815
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,37 @@
+github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
+github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.0.0-rc1 h1:Mr8jIV7wDfLw5Fw6BPupm0aduTFdLjhI3wFuIIZKvO4=
+github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.0.0-rc1/go.mod h1:2s/IzRcxCszyNh760IjJiqoYHTnifk8ZeNYL33z8Pww=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.2 h1:eVKgfIdy9b6zbWBMgFpfDPoAMifwSZagU9HmEU6zgiI=
+github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/leekchan/accounting v1.0.0 h1:+Wd7dJ//dFPa28rc1hjyy+qzCbXPMR91Fb6F1VGTQHg=
+github.com/leekchan/accounting v1.0.0/go.mod h1:3timm6YPhY3YDaGxl0q3eaflX0eoSx3FXn7ckHe4tO0=
+github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU=
+github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 h1:pntxY8Ary0t43dCZ5dqY4YTJCObLY1kIXl0uzMv+7DE=
+github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
+github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
+github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
+github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/sqlite v1.1.5 h1:JU8G59VyKu1x1RMQgjefQnkZjDe9wHc1kARDZPu5dZs=
+gorm.io/driver/sqlite v1.1.5/go.mod h1:NpaYMcVKEh6vLJ47VP6T7Weieu4H1Drs3dGD/K6GrGc=
+gorm.io/gorm v1.21.15 h1:gAyaDoPw0lCyrSFWhBlahbUA1U4P5RViC1uIqoB+1Rk=
+gorm.io/gorm v1.21.15/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..d25d02d
--- /dev/null
+++ b/main.go
@@ -0,0 +1,239 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ log "github.com/sirupsen/logrus"
+ "gorm.io/gorm"
+)
+
+// AppName to store application name
+var AppName string = "flexassistant"
+
+// AppVersion to set version at compilation time
+var AppVersion string = "9999"
+
+// GitCommit to set git commit at compilation time (can be empty)
+var GitCommit string
+
+// GoVersion to set Go version at compilation time
+var GoVersion string
+
+// MaxPayments defaults
+const MaxPayments = 10
+
+// MaxBlocks defaults
+const MaxBlocks = 50
+
+// initialize logging
+func init() {
+ log.SetOutput(os.Stdout)
+}
+
+func main() {
+ config := NewConfig()
+ version := flag.Bool("version", false, "Print version and exit")
+ quiet := flag.Bool("quiet", false, "Log errors only")
+ verbose := flag.Bool("verbose", false, "Print more logs")
+ debug := flag.Bool("debug", false, "Print even more logs")
+ configFileName := flag.String("config", AppName+".yaml", "Configuration file name")
+ flag.Parse()
+
+ if *version {
+ showVersion()
+ return
+ }
+
+ // Logs and configuration
+ log.SetLevel(log.WarnLevel)
+ if *debug {
+ log.SetLevel(log.DebugLevel)
+ }
+ if *verbose {
+ log.SetLevel(log.InfoLevel)
+ }
+ if *quiet {
+ log.SetLevel(log.ErrorLevel)
+ }
+
+ if *configFileName != "" {
+ err := config.ReadFile(*configFileName)
+ if err != nil {
+ log.Fatalf("Cannot parse configuration file: %v", err)
+ }
+ }
+
+ // Database
+ var db *gorm.DB
+ db, err := NewDatabase(config.DatabaseFile)
+ if err != nil {
+ log.Fatalf("Could not create database: %v", err)
+ }
+
+ if err := CreateDatabaseObjects(db); err != nil {
+ log.Fatalf("Could not create objects: %v", err)
+ }
+
+ // API client
+ client := NewFlexpoolClient()
+
+ // Notifications
+ notifier, err := NewTelegramNotifier(&config.TelegramConfig)
+ if err != nil {
+ log.Fatalf("Could not create notifier: %v", err)
+ }
+
+ // Limits
+ var maxPayments int
+ if config.MaxPayments > 0 {
+ maxPayments = config.MaxPayments
+ } else {
+ maxPayments = MaxPayments
+ }
+
+ var maxBlocks int
+ if config.MaxBlocks > 0 {
+ maxBlocks = config.MaxBlocks
+ } else {
+ maxBlocks = MaxBlocks
+ }
+
+ // Handle miners
+ for _, configuredMiner := range config.Miners {
+ miner, err := NewMiner(configuredMiner.Address)
+ if err != nil {
+ log.Warnf("Could not parse miner: %v", err)
+ continue
+ }
+
+ var dbMiner Miner
+ trx := db.Where(Miner{Address: miner.Address}).Attrs(Miner{Address: miner.Address, Coin: miner.Coin}).FirstOrCreate(&dbMiner)
+ if trx.Error != nil {
+ log.Warnf("Cannot fetch miner %s from database: %v", miner, trx.Error)
+ }
+
+ // Balance management
+ if configuredMiner.EnableBalance {
+ // Balance have never been persisted, skip notifications
+ notify := true
+ if dbMiner.Balance == 0 {
+ notify = false
+ }
+
+ log.Debugf("Fetching balance for %s", miner)
+ balance, err := client.MinerBalance(miner.Coin, miner.Address)
+ if err != nil {
+ log.Warnf("Could not fetch unpaid balance: %v", err)
+ continue
+ }
+ log.Debugf("Unpaid balance %.0f", balance)
+ miner.Balance = balance
+ if miner.Balance != dbMiner.Balance {
+ dbMiner.Balance = balance
+ if trx = db.Save(&dbMiner); trx.Error != nil {
+ log.Warnf("Cannot update miner: %v", trx.Error)
+ continue
+ }
+ if notify {
+ err = notifier.NotifyBalance(*miner)
+ if err != nil {
+ log.Warnf("Cannot send notification: %v", err)
+ continue
+ }
+ log.Infof("Balance notification sent for %s", miner)
+ }
+ }
+ }
+
+ // Payments management
+ if configuredMiner.EnablePayments {
+ // Payments have never been persisted, skip notifications
+ notify := true
+ if dbMiner.LastPaymentTimestamp == 0 {
+ notify = false
+ }
+
+ log.Debugf("Fetching payments for %s", miner)
+
+ payments, err := client.MinerPayments(miner.Coin, miner.Address, maxPayments)
+ if err != nil {
+ log.Warnf("Could not fetch payments: %v", err)
+ continue
+ }
+ for _, payment := range payments {
+ log.Debugf("Fetched %s", payment)
+ if dbMiner.LastPaymentTimestamp < payment.Timestamp {
+ dbMiner.LastPaymentTimestamp = payment.Timestamp
+ if trx = db.Save(&dbMiner); trx.Error != nil {
+ log.Warnf("Cannot update miner: %v", trx.Error)
+ continue
+ }
+ if notify {
+ err = notifier.NotifyPayment(*miner, *payment)
+ if err != nil {
+ log.Warnf("Cannot send notification: %v", err)
+ continue
+ }
+ log.Infof("Payment notification sent for %s", payment)
+ }
+ }
+ }
+ }
+ }
+
+ // Handle pools
+ for _, configuredPool := range config.Pools {
+
+ pool := NewPool(configuredPool.Coin)
+
+ var dbPool Pool
+ trx := db.Where(Pool{Coin: pool.Coin}).Attrs(Pool{Coin: pool.Coin}).FirstOrCreate(&dbPool)
+ if trx.Error != nil {
+ log.Warnf("Cannot fetch pool %s from database: %v", pool, trx.Error)
+ }
+
+ // Blocks management
+ if configuredPool.EnableBlocks {
+
+ // Block number has never been persisted, skip notifications
+ notify := true
+ if dbPool.LastBlockNumber == 0 {
+ notify = false
+ }
+
+ log.Debugf("Fetching blocks for %s", pool)
+ blocks, err := client.PoolBlocks(pool.Coin, maxBlocks)
+ if err != nil {
+ log.Warnf("Could not fetch blocks: %v", err)
+ } else {
+ for _, block := range blocks {
+ log.Debugf("Fetched %s", block)
+ if dbPool.LastBlockNumber < block.Number {
+ dbPool.LastBlockNumber = block.Number
+ if trx = db.Save(&dbPool); trx.Error != nil {
+ log.Warnf("Cannot update pool: %v", trx.Error)
+ continue
+ }
+ if notify {
+ err = notifier.NotifyBlock(*pool, *block)
+ if err != nil {
+ log.Warnf("Cannot send notification: %v", err)
+ continue
+ }
+ log.Infof("Block notification sent for %s", block)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+func showVersion() {
+ if GitCommit != "" {
+ AppVersion = fmt.Sprintf("%s-%s", AppVersion, GitCommit)
+ }
+ fmt.Printf("%s version %s (compiled with %s)\n", AppName, AppVersion, GoVersion)
+}
diff --git a/miner.go b/miner.go
new file mode 100644
index 0000000..980d841
--- /dev/null
+++ b/miner.go
@@ -0,0 +1,74 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+
+ "gorm.io/gorm"
+)
+
+// CoinLengthETH represents the size of an ETH address
+const CoinLengthETH = 42
+
+// CoinLengthXCH represents the size of an XCH address
+const CoinLengthXCH = 62
+
+// Miner to store miner attributes
+type Miner struct {
+ gorm.Model
+ Coin string
+ Address string `gorm:"unique;not null"`
+ Balance float64
+ LastPaymentTimestamp float64
+}
+
+// NewMiner creates a Miner
+func NewMiner(address string) (*Miner, error) {
+ miner := &Miner{Address: address}
+ coin, err := miner.ParseCoin()
+ if err != nil {
+ return nil, err
+ }
+ miner.Coin = coin
+ return miner, nil
+}
+
+// ParseCoin deduces the currency given the miner address
+func (m *Miner) ParseCoin() (coin string, err error) {
+ if m.Address == "" {
+ return "", fmt.Errorf("Miner address is empty")
+ }
+ if len(m.Address) == CoinLengthETH && strings.HasPrefix(m.Address, "0x") {
+ return "eth", nil
+ }
+ if len(m.Address) == CoinLengthXCH && strings.HasPrefix(m.Address, "xch") {
+ return "xch", nil
+ }
+ return "", fmt.Errorf("Unsupported address")
+}
+
+// String represents Miner to a printable format
+func (m *Miner) String() string {
+ return fmt.Sprintf("Miner<%s>", m.Address)
+}
+
+// Payment to store payment attributes
+type Payment struct {
+ Hash string
+ Value float64
+ Timestamp float64
+}
+
+// NewPayment creates a Payment
+func NewPayment(hash string, value float64, timestamp float64) *Payment {
+ return &Payment{
+ Hash: hash,
+ Value: value,
+ Timestamp: timestamp,
+ }
+}
+
+// String represents a Payment to a printable format
+func (p *Payment) String() string {
+ return fmt.Sprintf("Payment<%s>", p.Hash)
+}
diff --git a/notification.go b/notification.go
new file mode 100644
index 0000000..a4e3713
--- /dev/null
+++ b/notification.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/leekchan/accounting"
+
+ telegram "github.com/go-telegram-bot-api/telegram-bot-api/v5"
+ log "github.com/sirupsen/logrus"
+)
+
+// Notifier interface to define how to send all kind of notifications
+type Notifier interface {
+ NotifyBalance(miner Miner, difference float64) error
+ NotifyPayment(miner Miner, payment Payment) error
+ NotifyBlock(pool Pool, block Block) error
+}
+
+// TelegramNotifier to send notifications using Telegram
+// Implements the Notifier interface
+type TelegramNotifier struct {
+ bot *telegram.BotAPI
+ chatID int64
+ channelName string
+}
+
+// NewTelegramNotifier to create a TelegramNotifier
+func NewTelegramNotifier(config *TelegramConfig) (*TelegramNotifier, error) {
+ bot, err := telegram.NewBotAPI(config.Token)
+ if err != nil {
+ return nil, err
+ }
+ log.Debugf("Connected to Telegram as %s", bot.Self.UserName)
+
+ return &TelegramNotifier{
+ bot: bot,
+ chatID: config.ChatID,
+ channelName: config.ChannelName,
+ }, nil
+}
+
+// sendMessage to send a generic message on Telegram
+func (t *TelegramNotifier) sendMessage(message string) error {
+ var request telegram.MessageConfig
+ if t.chatID != 0 {
+ request = telegram.NewMessage(t.chatID, message)
+ } else {
+ request = telegram.NewMessageToChannel(t.channelName, message)
+ }
+ request.DisableWebPagePreview = true
+ request.ParseMode = telegram.ModeMarkdown
+
+ response, err := t.bot.Send(request)
+ if err != nil {
+ return err
+ }
+ log.Debugf("Message %d sent to Telegram", response.MessageID)
+ return nil
+}
+
+// NotifyBalance to format and send a notification when the unpaid balance has changed
+// Implements the Notifier interface
+func (t *TelegramNotifier) NotifyBalance(miner Miner) error {
+ ac := accounting.Accounting{
+ Symbol: strings.ToUpper(miner.Coin),
+ Precision: 6,
+ Format: "%v %s",
+ }
+ convertedBalance, err := ConvertCurrency(miner.Coin, miner.Balance)
+ if err != nil {
+ return err
+ }
+ message := fmt.Sprintf("💰 *Balance* _%s_", ac.FormatMoney(convertedBalance))
+ return t.sendMessage(message)
+}
+
+// NotifyPayment to format and send a notification when a new payment has been detected
+// Implements the Notifier interface
+func (t *TelegramNotifier) NotifyPayment(miner Miner, payment Payment) error {
+ ac := accounting.Accounting{
+ Symbol: strings.ToUpper(miner.Coin),
+ Precision: 6,
+ Format: "%v %s",
+ }
+ convertedValue, err := ConvertCurrency(miner.Coin, payment.Value)
+ if err != nil {
+ return err
+ }
+
+ message := fmt.Sprintf("💵 *Payment* _%s_", ac.FormatMoney(convertedValue))
+ return t.sendMessage(message)
+}
+
+// NotifyBlock to format and send a notification when a new block has been detected
+// Implements the Notifier interface
+func (t *TelegramNotifier) NotifyBlock(pool Pool, block Block) error {
+ precision := 6
+ if pool.Coin == "xch" {
+ precision = 2
+ }
+ ac := accounting.Accounting{
+ Symbol: strings.ToUpper(pool.Coin),
+ Precision: precision,
+ Format: "%v %s",
+ }
+
+ convertedValue, err := ConvertCurrency(pool.Coin, block.Reward)
+ if err != nil {
+ return err
+ }
+ verb, err := ConvertAction(pool.Coin)
+ if err != nil {
+ return err
+ }
+ url, err := FormatBlockURL(pool.Coin, block.Hash)
+ if err != nil {
+ return err
+ }
+
+ message := fmt.Sprintf("🎉 *%s* [#%.0f](%s) _%s_", verb, block.Number, url, ac.FormatMoney(convertedValue))
+ return t.sendMessage(message)
+}
diff --git a/pool.go b/pool.go
new file mode 100644
index 0000000..d69947e
--- /dev/null
+++ b/pool.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+ "fmt"
+
+ "gorm.io/gorm"
+)
+
+// Pool to store pool attributes
+type Pool struct {
+ gorm.Model
+ Coin string `gorm:"unique;not null"`
+ LastBlockNumber float64
+}
+
+// NewPool creates a Pool
+func NewPool(coin string) *Pool {
+ return &Pool{Coin: coin}
+}
+
+// String represents Pool to a printable format
+func (p *Pool) String() string {
+ return fmt.Sprintf("Pool<%s>", p.Coin)
+}
+
+// Block to store block attributes
+type Block struct {
+ Hash string `gorm:"unique;not null"`
+ Number float64 `gorm:"not null"`
+ Reward float64 `gorm:"not null"`
+}
+
+// NewBlock creates a Block
+func NewBlock(hash string, number float64, reward float64) *Block {
+ return &Block{
+ Hash: hash,
+ Number: number,
+ Reward: reward,
+ }
+}
+
+// String represents Block to a printable format
+func (b *Block) String() string {
+ return fmt.Sprintf("Block<%.0f>", b.Number)
+}
diff --git a/static/screenshot.jpg b/static/screenshot.jpg
new file mode 100644
index 0000000..74cd6c3
Binary files /dev/null and b/static/screenshot.jpg differ
diff --git a/utils.go b/utils.go
new file mode 100644
index 0000000..fa57434
--- /dev/null
+++ b/utils.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+ "fmt"
+)
+
+// WeisToETHDivider to divide Weis to ETH
+const WeisToETHDivider = 1000000000000000000
+
+// MojoToXCHDivider to divide Mojo to XCH
+const MojoToXCHDivider = 1000000000000
+
+// ConvertCurrency divides the smallest unit of the currency to the currency itself
+// Example: for "eth", convert from Weis to ETH
+func ConvertCurrency(coin string, value float64) (float64, error) {
+ switch coin {
+ case "eth":
+ return ConvertWeis(value), nil
+ case "xch":
+ return ConvertMojo(value), nil
+ default:
+ return 0, fmt.Errorf("Coin %s not supported", coin)
+ }
+}
+
+// ConvertWeis converts the value from Weis to ETH
+func ConvertWeis(value float64) float64 {
+ return value / WeisToETHDivider
+}
+
+// ConvertMojo converts the value from Mojo to XCH
+func ConvertMojo(value float64) float64 {
+ return value / MojoToXCHDivider
+}
+
+// ConvertAction returns "Miner" for Ethereum and "Farmer" for Chia
+// Because Chia is farmed and not mined
+func ConvertAction(coin string) (string, error) {
+ switch coin {
+ case "eth":
+ return "Mined", nil
+ case "xch":
+ return "Farmed", nil
+ }
+ return "", fmt.Errorf("Coin %s not supported", coin)
+}
+
+// FormatBlockURL returns the URL on the respective blockchain explorer given the coin and the block hash
+func FormatBlockURL(coin string, hash string) (string, error) {
+ switch coin {
+ case "eth":
+ return fmt.Sprintf("https://etherscan.io/block/%s", hash), nil
+ case "xch":
+ return fmt.Sprintf("https://www.chiaexplorer.com/blockchain/block/%s", hash), nil
+ }
+ return "", fmt.Errorf("Coin %s not supported", coin)
+}
+
+// FormatTransactionURL returns the URL on the respective blockchain explorer given the coin and the transaction hash
+func FormatTransactionURL(coin string, hash string) (string, error) {
+ switch coin {
+ case "eth":
+ return fmt.Sprintf("https://etherscan.io/tx/%s", hash), nil
+ case "xch":
+ return fmt.Sprintf("https://www.chiaexplorer.com/blockchain/coin/%s", hash), nil
+ }
+ return "", fmt.Errorf("Coin %s not supported", coin)
+}