diff --git a/accountProviders.go b/accountProviders.go index ccbcb13..825f040 100644 --- a/accountProviders.go +++ b/accountProviders.go @@ -2,6 +2,7 @@ package main import ( "code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/bitcoin" + "code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/bitcoinElectrum" "code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/questrade" "code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/staticjsonFinnhub" "code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/staticjsonYahooFinance" @@ -21,4 +22,5 @@ var allProviders []AccountProvider = []AccountProvider{ &bitcoin.Provider{}, &staticjsonFinnhub.Provider{}, &staticjsonYahooFinance.Provider{}, + &bitcoinElectrum.Provider{}, } diff --git a/go.mod b/go.mod index fe9a80b..890734c 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,20 @@ module code.stevenpolley.net/steven/ynab-portfolio-monitor go 1.22 -// Goal is no third party dependencies \ No newline at end of file +// Goal is no third party dependencies +// Edit 2025-09-30 - brought in btcd dependency because crypto is hard + +require ( + github.com/btcsuite/btcd v0.24.2 + github.com/btcsuite/btcd/btcutil v1.1.6 +) + +require ( + github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect + github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect + golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c064ade --- /dev/null +++ b/go.sum @@ -0,0 +1,117 @@ +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= +github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c= +github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed h1:J22ig1FUekjjkmZUM7pTKixYm8DvrYsvrBZdunYeIuQ= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/providers/bitcoinElectrum/bitcoin.go b/providers/bitcoinElectrum/bitcoin.go new file mode 100644 index 0000000..366a274 --- /dev/null +++ b/providers/bitcoinElectrum/bitcoin.go @@ -0,0 +1,91 @@ +package bitcoinElectrum + +import ( + "crypto/sha256" + "encoding/hex" + "log" + "sync/atomic" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" +) + +// Derives a P2WPKH address at a specific index from a branch key. +func deriveAddress(branchKey *hdkeychain.ExtendedKey, index uint32) (btcutil.Address, error) { + childKey, err := branchKey.Derive(index) + if err != nil { + return nil, err + } + pubKey, err := childKey.ECPubKey() + if err != nil { + return nil, err + } + // Note: For zpub/vpub, use NewAddressWitnessPubKeyHash. + // For ypub/upub, use NewAddressScriptHash with a P2WPKH script. + // For xpub/tpub, use NewAddressPubKeyHash. + return btcutil.NewAddressWitnessPubKeyHash(btcutil.Hash160(pubKey.SerializeCompressed()), &chaincfg.MainNetParams) +} + +// Converts a btcutil.Address to an Electrum scripthash. +func addressToScriptHash(address btcutil.Address) (string, error) { + script, err := txscript.PayToAddrScript(address) + if err != nil { + return "", err + } + hash := sha256.Sum256(script) + // Reverse byte order for Electrum protocol + for i, j := 0, len(hash)-1; i < j; i, j = i+1, j-1 { + hash[i], hash[j] = hash[j], hash[i] + } + return hex.EncodeToString(hash[:]), nil +} + +// Scans a derivation branch (m/0 or m/1) for balances until the gap limit is reached. +func scanBranch(masterKey *hdkeychain.ExtendedKey, branch uint32, gapLimit int, spvServer string) int64 { + log.Printf("Scanning branch m/%d/k...", branch) + + branchKey, err := masterKey.Derive(branch) + if err != nil { + log.Printf("Error deriving branch %d: %v", branch, err) + return 0 + } + + var branchTotalBalance int64 + unusedAddressCount := 0 + + for i := uint32(0); ; i++ { + if unusedAddressCount >= gapLimit { + log.Printf("Gap limit of %d reached on branch m/%d. Stopping scan.", gapLimit, branch) + break + } + + // 3. Derive child address + address, err := deriveAddress(branchKey, i) + if err != nil { + log.Printf("Could not derive address at index %d on branch %d: %v", i, branch, err) + continue + } + + // 4. Get balance from Electrum server + balance, err := getAddressBalance(spvServer, address) + if err != nil { + log.Printf("Error getting balance for %s: %v", address.EncodeAddress(), err) + // On error, we can't know if it's used, so we continue scanning. + unusedAddressCount = 0 + continue + } + + total := balance.Result.Confirmed + balance.Result.Unconfirmed + if total > 0 { + log.Printf("Found balance for m/%d/%d (%s): %.8f BTC", branch, i, address.EncodeAddress(), btcutil.Amount(total).ToBTC()) + atomic.AddInt64(&branchTotalBalance, total) + unusedAddressCount = 0 // Reset gap counter on finding a used address + } else { + log.Printf("No balance for m/%d/%d (%s)", branch, i, address.EncodeAddress()) + unusedAddressCount++ + } + } + return branchTotalBalance +} diff --git a/providers/bitcoinElectrum/fiat.go b/providers/bitcoinElectrum/fiat.go new file mode 100644 index 0000000..96a9595 --- /dev/null +++ b/providers/bitcoinElectrum/fiat.go @@ -0,0 +1,63 @@ +package bitcoinElectrum + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const fiatConvertURL = "https://api.coingecko.com/api/v3/coins/bitcoin?localization=false&tickers=false&market_data=true&community_data=false&developer_data=false&sparkline=false" + +type coinGeckoResponse struct { + MarketData struct { + CurrentPrice struct { + CAD int `json:"cad"` + } `json:"current_price"` + } `json:"market_data"` +} + +func convertBTCToCAD(amount int, coinGeckoApiKey string) (int, error) { + coinGeckoData := &coinGeckoResponse{} + + req, err := http.NewRequest("GET", fmt.Sprintf("%s&x-cg-demo-api-key=%s", fiatConvertURL, coinGeckoApiKey), nil) + if err != nil { + return 0, fmt.Errorf("failed to create new GET request: %v", err) + } + + client := http.Client{Transport: &http.Transport{ResponseHeaderTimeout: 15 * time.Second}} + res, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("http GET request failed: %v", err) + } + + err = processResponse(res, coinGeckoData) + if err != nil { + return 0, fmt.Errorf("failed to process response: %v", err) + } + + return (amount * int(coinGeckoData.MarketData.CurrentPrice.CAD*1000)) / 100000000, nil // one BTC = one hundred million satoshi's +} + +// processResponse takes the body of an HTTP response, and either returns +// the error code, or unmarshalls the JSON response, extracts +// rate limit info, and places it into the object +// output parameter. This function closes the response body after reading it. +func processResponse(res *http.Response, out interface{}) error { + body, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + return fmt.Errorf("failed to read response body: %v", err) + } + + if res.StatusCode != 200 && res.StatusCode != 201 { + return fmt.Errorf("unexpected http status code '%d': %v", res.StatusCode, err) + } + + err = json.Unmarshal(body, out) + if err != nil { + return fmt.Errorf("failed to unmarshal response body: %v", err) + } + return nil +} diff --git a/providers/bitcoinElectrum/providerImpl.go b/providers/bitcoinElectrum/providerImpl.go new file mode 100644 index 0000000..90995de --- /dev/null +++ b/providers/bitcoinElectrum/providerImpl.go @@ -0,0 +1,62 @@ +package bitcoinElectrum + +import ( + "fmt" + "os" + "sync" + "sync/atomic" + + "github.com/btcsuite/btcd/btcutil/hdkeychain" +) + +type Provider struct { + masterKey *hdkeychain.ExtendedKey + ynabAccountID string // YNAB account ID this provider updates - all bitcoin addresses are summed up and mapped to this YNAB account + spvServer string + gapLimit int + coinGeckoApiKey string +} + +func (p *Provider) Name() string { + return "Bitcoin - Electrum SPV" +} + +func (p *Provider) Configure() error { + var err error + zpub := os.Getenv("bitcoin_zpub") + p.ynabAccountID = os.Getenv("bitcoin_ynab_account") + p.spvServer = os.Getenv("bitcoin_spv_server") + p.coinGeckoApiKey = os.Getenv("bitcoin_coingecko_api_key") + p.gapLimit = 20 + if zpub == "" || p.ynabAccountID == "" || p.spvServer == "" || p.coinGeckoApiKey == "" { + return fmt.Errorf("this account provider is not configured") + } + + p.masterKey, err = hdkeychain.NewKeyFromString(zpub) + if err != nil { + return fmt.Errorf("failed to obtain master key from zpub: %v", err) + } + + return nil +} + +// Returns slices of account balances and mapped YNAB account IDs, along with an error +func (p *Provider) GetBalances() ([]int, []string, error) { + var totalSats int64 + var wg sync.WaitGroup + + // branch 0 = regular + // branch 1 = change addresses + for _, branch := range []uint32{0, 1} { + wg.Add(1) + go func(branch uint32) { + defer wg.Done() + branchSats := scanBranch(p.masterKey, branch, p.gapLimit, p.spvServer) + atomic.AddInt64(&branchSats, totalSats) + }(branch) + } + + wg.Wait() + + return nil, nil, nil +} diff --git a/providers/bitcoinElectrum/spv.go b/providers/bitcoinElectrum/spv.go new file mode 100644 index 0000000..5add1e4 --- /dev/null +++ b/providers/bitcoinElectrum/spv.go @@ -0,0 +1,76 @@ +package bitcoinElectrum + +import ( + "bufio" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "time" + + "github.com/btcsuite/btcd/btcutil" +) + +// --- Electrum JSON-RPC Structures --- +type ElectrumRequest struct { + ID int `json:"id"` + Method string `json:"method"` + Params []interface{} `json:"params"` +} + +type ElectrumBalanceResponse struct { + Result struct { + Confirmed int64 `json:"confirmed"` + Unconfirmed int64 `json:"unconfirmed"` + } `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// Connects to Electrum and queries the balance for a single address. +func getAddressBalance(electrumServer string, address btcutil.Address) (*ElectrumBalanceResponse, error) { + scripthash, err := addressToScriptHash(address) + if err != nil { + return nil, fmt.Errorf("could not create scripthash: %w", err) + } + + // Connect to the server with SSL/TLS + conf := &tls.Config{InsecureSkipVerify: true} // Use InsecureSkipVerify for public servers, but be aware of MITM risks + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", electrumServer, conf) + if err != nil { + return nil, fmt.Errorf("could not connect to electrum server: %w", err) + } + defer conn.Close() + + // Prepare the JSON-RPC request + request := ElectrumRequest{ + ID: 1, + Method: "blockchain.scripthash.get_balance", + Params: []interface{}{scripthash}, + } + requestBytes, _ := json.Marshal(request) + + // Send the request (with a newline delimiter) + _, err = fmt.Fprintf(conn, "%s\n", requestBytes) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + // Read the response + reader := bufio.NewReader(conn) + responseLine, err := reader.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var response ElectrumBalanceResponse + if err := json.Unmarshal([]byte(responseLine), &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + if response.Error != nil { + return nil, fmt.Errorf("electrum server error: %s", response.Error.Message) + } + + return &response, nil +}