feat: set up project

This commit is contained in:
2023-07-19 11:47:46 +02:00
parent fac61db3f1
commit b426a36570
45 changed files with 2388 additions and 4 deletions

View File

@@ -0,0 +1,53 @@
package btc
import (
"fmt"
"net/http"
)
type BitcoinService struct {
host string
auth string
zmqAddress string
walletAddress string
client *http.Client
testNet bool
}
func NewBitcoinService(host, auth, zmqAddress, walletAddress string) *BitcoinService {
bs := BitcoinService{
host: host,
auth: auth,
zmqAddress: zmqAddress,
walletAddress: walletAddress,
client: &http.Client{},
}
from, err := bs.getAddressGroupings(false)
if err != nil {
panic(fmt.Errorf("bs.getAddressGroupings %s", err))
}
found := false
for _, a := range from {
if a.address == bs.walletAddress {
found = true
}
}
if !found {
return nil
}
return &bs
}
func (bc *BitcoinService) WithTestnet() *BitcoinService {
bc.testNet = true
return bc
}
func (bc *BitcoinService) Explorer(tx string) string {
if bc.testNet {
return "https://testnet.bitcoinexplorer.org/tx/" + tx
}
return "https://btcscan.org/tx/" + tx
}

View File

@@ -0,0 +1,61 @@
package btc
import (
"encoding/json"
)
type decodeRawTransactionResponseResult struct {
Txid string `json:"txid"`
Hash string `json:"hash"`
Version int `json:"version"`
Size int `json:"size"`
Vsize int `json:"vsize"`
Weight int `json:"weight"`
Locktime int `json:"locktime"`
Vin []struct {
Txid string `json:"txid"`
Vout int `json:"vout"`
ScriptSig struct {
Asm string `json:"asm"`
Hex string `json:"hex"`
} `json:"scriptSig"`
Txinwitness []string `json:"txinwitness"`
Sequence int64 `json:"sequence"`
} `json:"vin"`
Vout []struct {
Value float64 `json:"value"`
N int `json:"n"`
ScriptPubKey struct {
Asm string `json:"asm"`
Hex string `json:"hex"`
Address string `json:"address,omitempty"`
Type string `json:"type,omitempty"`
} `json:"scriptPubKey,omitempty"`
} `json:"vout"`
}
// func (d decodeRawTransactionResponseResult) getAddressRestFunds(amountPlusFee float64) string {
// address := ""
// for _, vout := range d.Vout {
// if vout.Value > amountPlusFee {
// address = vout.ScriptPubKey.Address
// }
// }
// return address
// }
type decodeRawTransactionResponse struct {
Result decodeRawTransactionResponseResult `json:"result"`
RPCResponse
}
func NewdecodeRawTransactionResponse(bts []byte) (*decodeRawTransactionResponse, error) {
resp := new(decodeRawTransactionResponse)
err := json.Unmarshal(bts, resp)
if err != nil {
return nil, err
}
return resp, nil
}

View File

@@ -0,0 +1,52 @@
package btc
import (
"encoding/json"
"fmt"
)
func NewGetBalanceParams() []interface{} {
return []interface{}{}
}
type GetBalanceResponse struct {
Result float64 `json:"result"`
RPCResponse
}
func NewGetBalanceResponse(bts []byte) (*GetBalanceResponse, error) {
resp := new(GetBalanceResponse)
err := json.Unmarshal(bts, resp)
if err != nil {
return nil, err
}
return resp, nil
}
func (b *BitcoinService) GetBalance() (float64, error) {
req := b.NewRPCRequest().WithMethod(GET_BALANCE)
if req == nil {
return 0.0, fmt.Errorf("NewRPCRequest %s is nil", GET_BALANCE)
}
resp, err := req.Call(NewGetBalanceParams()...)
if err != nil {
return 0.0, fmt.Errorf("req.Call: %s", err)
}
strinResp := string(resp)
if strinResp == "" {
return 0.0, fmt.Errorf("strinResp: %s", err)
}
trx, err := NewGetBalanceResponse(resp)
if err != nil {
return 0.0, fmt.Errorf("NewGetBalanceResponse: %s", err)
}
if trx.Error != nil {
return 0.0, fmt.Errorf("raw.Error: %+v", trx.Error)
}
return trx.Result, nil
}

View File

@@ -0,0 +1,14 @@
package btc
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_bitcoinService_GetBalance(t *testing.T) {
th := newTestHelper()
balance, err := th.b.GetBalance()
require.NoError(t, err)
require.Greater(t, balance, 0.0)
}

View File

@@ -0,0 +1,72 @@
package btc
import (
"encoding/json"
"fmt"
)
func NewGetBlockParams(blockHash string) []interface{} {
return []interface{}{blockHash}
}
type GetBlockResponse struct {
Result GetBlockResponseResult `json:"result"`
RPCResponse
}
type GetBlockResponseResult struct {
Hash string `json:"hash"`
Confirmations int `json:"confirmations"`
Height int `json:"height"`
Version int `json:"version"`
VersionHex string `json:"versionHex"`
Merkleroot string `json:"merkleroot"`
Time int `json:"time"`
Mediantime int `json:"mediantime"`
Nonce int `json:"nonce"`
Bits string `json:"bits"`
Difficulty float64 `json:"difficulty"`
Chainwork string `json:"chainwork"`
NTx int `json:"nTx"`
Previousblockhash string `json:"previousblockhash"`
Strippedsize int `json:"strippedsize"`
Size int `json:"size"`
Weight int `json:"weight"`
Tx []string `json:"tx"`
}
func NewGetBlockResponse(bts []byte) (*GetBlockResponse, error) {
resp := new(GetBlockResponse)
err := json.Unmarshal(bts, resp)
if err != nil {
return nil, err
}
return resp, nil
}
func (b *BitcoinService) getBlock(blockHash string) (*GetBlockResponseResult, error) {
req := b.NewRPCRequest().WithMethod(GET_BLOCK)
if req == nil {
return nil, fmt.Errorf("NewRPCRequest %s is nil", GET_BLOCK)
}
resp, err := req.Call(NewGetBlockParams(blockHash)...)
if err != nil {
return nil, fmt.Errorf("req.Call: %s", err)
}
strinResp := string(resp)
if strinResp == "" {
return nil, fmt.Errorf("strinResp: %s", err)
}
block, err := NewGetBlockResponse(resp)
if err != nil {
return nil, fmt.Errorf("NewGetBlockResponse: %s", err)
}
if block.Error != nil {
return nil, fmt.Errorf("raw.Error: %+v", block.Error)
}
return &block.Result, nil
}

View File

@@ -0,0 +1,16 @@
package btc
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_bitcoinService_GetBlock(t *testing.T) {
th := newTestHelper()
blockHash := "00000000000000043d385a031abd1f911aae1783810ec5f59a1db9e3ff7eac80"
block, err := th.b.getBlock(blockHash)
require.NoError(t, err)
require.NotNil(t, block)
require.Greater(t, len(block.Tx), 0)
}

View File

@@ -0,0 +1,114 @@
package btc
import (
"encoding/json"
"fmt"
"strings"
)
func getRawTransactionParams(trxid, blockchash string) []interface{} {
return []interface{}{trxid, true, blockchash}
}
type GetRawTransactionResponseResult struct {
InActiveChain bool `json:"in_active_chain"`
Txid string `json:"txid"`
Hash string `json:"hash"`
Version int `json:"version"`
Size int `json:"size"`
Vsize int `json:"vsize"`
Weight int `json:"weight"`
Locktime int `json:"locktime"`
Vin []struct {
Txid string `json:"txid"`
Vout int `json:"vout"`
ScriptSig struct {
Asm string `json:"asm"`
Hex string `json:"hex"`
} `json:"scriptSig"`
Txinwitness []string `json:"txinwitness"`
Sequence int64 `json:"sequence"`
} `json:"vin"`
Vout []GetRawTransactionResponseResultVout `json:"vout"`
Hex string `json:"hex"`
Blockhash string `json:"blockhash"`
Confirmations int `json:"confirmations"`
Time int `json:"time"`
Blocktime int `json:"blocktime"`
}
type GetRawTransactionResponseResultVout struct {
Value float64 `json:"value"`
N int `json:"n"`
ScriptPubKey struct {
Asm string `json:"asm"`
Hex string `json:"hex"`
Address string `json:"address,omitempty"`
Type string `json:"type"`
} `json:"scriptPubKey,omitempty"`
}
func (g GetRawTransactionResponseResult) getOpReturn() string {
opReturn := ""
for _, v := range g.Vout {
if strings.HasPrefix(v.ScriptPubKey.Asm, "OP_RETURN") {
opReturn = v.ScriptPubKey.Asm
break
}
}
if !strings.HasPrefix(opReturn, "OP_RETURN") {
return ""
}
hexMessage := strings.Split(opReturn, " ")[len(strings.Split(opReturn, " "))-1]
return hexMessage
}
type GetRawTransactionResponse struct {
Result GetRawTransactionResponseResult `json:"result"`
RPCResponse
}
func NewGetRawTransactionResponse(bts []byte) (*GetRawTransactionResponse, error) {
resp := new(GetRawTransactionResponse)
err := json.Unmarshal(bts, resp)
if err != nil {
return nil, err
}
return resp, nil
}
func (b *BitcoinService) getRawTransaction(trxid, blockhash string) (*GetRawTransactionResponseResult, error) {
req := b.NewRPCRequest().WithMethod(GET_RAW_TRANSACTION)
if req == nil {
return nil, fmt.Errorf("NewRPCRequest %s is nil", GET_RAW_TRANSACTION)
}
bts := getRawTransactionParams(trxid, blockhash)
if bts == nil {
return nil, fmt.Errorf("getRawTransactionParams is nil")
}
resp, err := req.Call(bts...)
if err != nil {
return nil, fmt.Errorf("req.Call: %s", err)
}
strinResp := string(resp)
if strinResp == "" {
return nil, fmt.Errorf("strinResp: %s", err)
}
trx, err := NewGetRawTransactionResponse(resp)
if err != nil {
return nil, fmt.Errorf("NewGetRawTransactionResponse: %s", err)
}
if trx.Error != nil {
return nil, fmt.Errorf("raw.Error: %+v", trx.Error)
}
if trx.Result.Txid != trxid {
return nil, fmt.Errorf("trx.Result.Txid: %s != trxid %s", trx.Result.Txid, trxid)
}
return &trx.Result, nil
}

View File

@@ -0,0 +1,25 @@
package btc
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_bitcoinService_getRawTransaction(t *testing.T) {
th := newTestHelper()
txid := "873d5516a9cacc065bb30831bf4855b058f59a2a4877e08e0e28c22c51c58e39"
tx, err := th.b.getTransaction(txid)
require.NoError(t, err)
rawTrx, err := th.b.getRawTransaction(tx.Txid, tx.Blockhash)
require.NoError(t, err)
require.Equal(t, rawTrx.Txid, txid)
require.NotEmpty(t, rawTrx.getOpReturn())
// hexMessage := rawTrx.getOpReturn()
// bts, err := hex.DecodeString(hexMessage)
// require.NoError(t, err)
// require.Equal(t, testMessage, string(bts))
}

View File

@@ -0,0 +1,84 @@
package btc
import (
"encoding/json"
"fmt"
)
func getTransactionParams(trxid string) []interface{} {
return []interface{}{trxid}
}
type GetTransactionResponseResult struct {
Amount float64 `json:"amount"`
Fee float64 `json:"fee"`
Confirmations int `json:"confirmations"`
Blockhash string `json:"blockhash"`
Blockheight int `json:"blockheight"`
Blockindex int `json:"blockindex"`
Blocktime int `json:"blocktime"`
Txid string `json:"txid"`
Walletconflicts []interface{} `json:"walletconflicts"`
Time int `json:"time"`
Timereceived int `json:"timereceived"`
Bip125Replaceable string `json:"bip125-replaceable"`
Details []struct {
Address string `json:"address,omitempty"`
Category string `json:"category"`
Amount float64 `json:"amount"`
Vout int `json:"vout"`
Fee float64 `json:"fee"`
Abandoned bool `json:"abandoned"`
} `json:"details"`
Hex string `json:"hex"`
}
type GetTransactionResponse struct {
Result GetTransactionResponseResult `json:"result"`
RPCResponse
}
func NewGetTransactionResponse(bts []byte) (*GetTransactionResponse, error) {
resp := new(GetTransactionResponse)
err := json.Unmarshal(bts, resp)
if err != nil {
return nil, err
}
return resp, nil
}
func (b *BitcoinService) getTransaction(trxid string) (*GetTransactionResponseResult, error) {
req := b.NewRPCRequest().WithMethod(GET_TRANSACTION)
if req == nil {
return nil, fmt.Errorf("NewRPCRequest %s is nil", GET_TRANSACTION)
}
bts := getTransactionParams(trxid)
if bts == nil {
return nil, fmt.Errorf("NewGetTransactionParams is nil")
}
resp, err := req.Call(bts...)
if err != nil {
return nil, fmt.Errorf("req.Call: %s", err)
}
strinResp := string(resp)
if strinResp == "" {
return nil, fmt.Errorf("strinResp: %s", err)
}
trx, err := NewGetTransactionResponse(resp)
if err != nil {
return nil, fmt.Errorf("NewGetTransactionResponse: %s", err)
}
if trx.Error != nil {
return nil, fmt.Errorf("raw.Error: %+v", trx.Error)
}
if trx.Result.Txid != trxid {
return nil, fmt.Errorf("trx.Result.Txid: %s != trxid %s", trx.Result.Txid, trxid)
}
return &trx.Result, nil
}

View File

@@ -0,0 +1,15 @@
package btc
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_bitcoinService_GetTransaction(t *testing.T) {
th := newTestHelper()
txid := "1dd9a1be3dc4feba3031cda110bd043535bc170a34a7664b231ccda3c3928e93"
trx, err := th.b.getTransaction(txid)
require.NoError(t, err)
require.Equal(t, trx.Txid, txid)
}

View File

@@ -0,0 +1,124 @@
package btc
import (
"encoding/json"
"fmt"
"log"
)
type ListAddressGroupingsResponse struct {
Result [][][]any `json:"result"`
Error any `json:"error"`
ID string `json:"id"`
}
func NewListAddressGroupingsResponse(bts []byte) (*ListAddressGroupingsResponse, error) {
resp := new(ListAddressGroupingsResponse)
err := json.Unmarshal(bts, resp)
if err != nil {
return nil, err
}
return resp, nil
}
// func (b *bitcoinService) listaddressgroupings() ([][]any, error) {
// req := b.NewRPCRequest().WithMethod(LIST_ADDRESS_GROUPINGS)
// if req == nil {
// return nil, fmt.Errorf("NewRPCRequest %s is nil", LIST_ADDRESS_GROUPINGS)
// }
// resp, err := req.Call(nil)
// if err != nil {
// return nil, fmt.Errorf("req.Call: %s", err)
// }
// strinResp := string(resp)
// if strinResp == "" {
// return nil, fmt.Errorf("strinResp: %s", err)
// }
// log.Println(strinResp)
// addressResp, err := NewListAddressGroupingsResponse(resp)
// if err != nil {
// return nil, fmt.Errorf("NewFundTransactionResponse: %s", err)
// }
// if addressResp.Error != nil {
// return nil, fmt.Errorf("raw.Error: %+v", addressResp.Error)
// }
// if len(addressResp.Result) != 1 {
// return nil, fmt.Errorf("no addresses found")
// }
// if len(addressResp.Result[0]) <= 0 {
// return nil, fmt.Errorf("no addresses found")
// }
// return addressResp.Result[0], nil
// }
type AddressGrouping struct {
address string
funds float64
}
func (b *BitcoinService) listaddressgroupingsWithFunds() ([]AddressGrouping, error) {
return b.getAddressGroupings(true)
}
func (b *BitcoinService) getAddressGroupings(withFunds bool) ([]AddressGrouping, error) {
req := b.NewRPCRequest().WithMethod(LIST_ADDRESS_GROUPINGS)
if req == nil {
return nil, fmt.Errorf("NewRPCRequest %s is nil", LIST_ADDRESS_GROUPINGS)
}
resp, err := req.Call()
if err != nil {
return nil, fmt.Errorf("req.Call: %s", err)
}
strinResp := string(resp)
if strinResp == "" {
return nil, fmt.Errorf("strinResp: %s", err)
}
addressResp, err := NewListAddressGroupingsResponse(resp)
if err != nil {
return nil, fmt.Errorf("NewListAddressGroupingsResponse: %s", err)
}
if addressResp.Error != nil {
return nil, fmt.Errorf("raw.Error: %+v", addressResp.Error)
}
if len(addressResp.Result) != 1 {
return nil, fmt.Errorf("no addresses found")
}
if len(addressResp.Result[0]) <= 0 {
return nil, fmt.Errorf("no addresses found")
}
var addressList []AddressGrouping
for i := range addressResp.Result[0] {
addressRaw, fundsRaw := addressResp.Result[0][i][0], addressResp.Result[0][i][1]
address, ok := addressRaw.(string)
if !ok {
log.Fatalf("Address is not a string: %v", addressRaw)
continue
}
funds, ok := fundsRaw.(float64)
if !ok {
log.Fatalf("Funds is not a float64: %v", fundsRaw)
continue
}
if withFunds && funds <= 0.0 {
continue
}
addressList = append(addressList, AddressGrouping{address: address, funds: funds})
}
return addressList, nil
}

View File

@@ -0,0 +1,17 @@
package btc
import (
"log"
"testing"
"github.com/stretchr/testify/require"
)
func Test_bitcoinService_listaddressgroupingsWithFunds(t *testing.T) {
th := newTestHelper()
addresslistWithFunds, err := th.b.listaddressgroupingsWithFunds()
require.NoError(t, err)
require.Greater(t, len(addresslistWithFunds), 0)
log.Println("addresslist", addresslistWithFunds)
}

View File

@@ -0,0 +1,79 @@
package btc
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
"gitea.urkob.com/urko/btc-pay-checker/internal/domain"
)
func (b *BitcoinService) Notify(ctx context.Context, notifChan chan<- domain.Notification) {
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
q := &Zmq{}
if err := q.Connect(b.zmqAddress); err != nil {
log.Fatal("Failed to connect to ZeroMQ socket:", err)
}
blockChan := make(chan string)
go q.Listen(blockChan)
go func() {
for blockHash := range blockChan {
block, err := b.getBlock(blockHash)
if err != nil {
log.Println("Error b.GetBlock:", err)
continue
}
log.Println("block readed", block.Hash)
for _, txid := range block.Tx {
tx, err := b.getRawTransaction(txid, block.Hash)
if err != nil {
log.Println("Error b.getRawTransaction:", err)
continue
}
if !tx.InActiveChain {
log.Printf("tx is not active on chain | block %s | tx %s \n", blockHash, txid)
continue
}
receiverAddress := false
amount := 0.0
for _, output := range tx.Vout {
if output.ScriptPubKey.Address == b.walletAddress {
receiverAddress = true
amount = output.Value
break
}
}
if !receiverAddress {
continue
}
if receiverAddress {
log.Println("Transaction has been completed")
notifChan <- domain.Notification{
BlockHash: blockHash,
Tx: txid,
Amount: amount,
DoneAt: time.Now(),
}
break
}
}
}
}()
<-interrupt
if err := q.Close(); err != nil {
log.Println("Error closing ZeroMQ socket:", err)
}
}

View File

@@ -0,0 +1,92 @@
package btc
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
)
const (
GET_BLOCK RPCMethod = "getblock"
GET_BALANCE RPCMethod = "getbalance"
GET_RAW_TRANSACTION RPCMethod = "getrawtransaction"
GET_TRANSACTION RPCMethod = "gettransaction"
DECODE_RAW_TRANSACTION RPCMethod = "decoderawtransaction"
LIST_ADDRESS_GROUPINGS RPCMethod = "listaddressgroupings"
)
type RPCMethod string
type RPCRequest struct {
host string
auth string
Jsonrpc string `json:"jsonrpc"`
ID string `json:"id"`
Method RPCMethod `json:"method"`
Params []interface{} `json:"params,omitempty"`
debug bool
}
func (b *BitcoinService) NewRPCRequest() *RPCRequest {
return &RPCRequest{
host: b.host,
auth: b.auth,
Jsonrpc: "1.0",
ID: "curltest",
Params: make([]interface{}, 0),
}
}
func (r *RPCRequest) WithDebug() *RPCRequest {
r.debug = true
return r
}
func (r *RPCRequest) WithMethod(method RPCMethod) *RPCRequest {
r.Method = method
return r
}
func (r *RPCRequest) Call(params ...interface{}) ([]byte, error) {
if len(params) > 0 && params != nil {
r.Params = append(r.Params, params...)
}
reqBody, err := json.Marshal(r)
if err != nil {
fmt.Println(err)
return nil, err
}
if r.debug {
log.Printf("%s \n body: \n%s \n", r.Method, string(reqBody))
}
payload := bytes.NewReader(reqBody)
client := &http.Client{}
req, err := http.NewRequest(http.MethodPost, r.host, payload)
if err != nil {
fmt.Println(err)
return nil, err
}
req.Header.Add("Accept", "text/plain")
req.Header.Add("Authorization", r.auth)
req.Header.Add("Content-Type", "application/json")
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if r.debug {
log.Printf("body response: \n%s \n", body)
}
return body, nil
}

View File

@@ -0,0 +1,27 @@
package btc
import (
"log"
"testing"
"github.com/stretchr/testify/require"
)
func TestCreateSendTransaction(t *testing.T) {
th := newTestHelper()
req := th.b.
NewRPCRequest().
WithMethod(GET_BALANCE)
require.NotNil(t, req)
resp, err := req.Call()
require.NoError(t, err)
strinResp := string(resp)
require.NotEmpty(t, string(strinResp))
log.Println(strinResp)
// unspent, err := NewListUnspentResponse(resp)
// require.NoError(t, err)
// require.Nil(t, unspent.Error)
// require.Greater(t, len(unspent.Result), 0)
}

View File

@@ -0,0 +1,11 @@
package btc
type RPCResponseError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type RPCResponse struct {
Id string `json:"id"`
Error *RPCResponseError `json:"error"`
}

View File

@@ -0,0 +1,22 @@
package btc
import (
"gitea.urkob.com/urko/btc-pay-checker/kit"
"gitea.urkob.com/urko/btc-pay-checker/kit/cfg"
)
type testHelper struct {
testDeliveryAddress string
config *cfg.Config
b *BitcoinService
}
func newTestHelper() *testHelper {
config := cfg.NewConfig(kit.RootDir() + "/.test.env")
testDeliveryAddress := config.WalletAddress
return &testHelper{
config: config,
testDeliveryAddress: testDeliveryAddress,
b: NewBitcoinService(config.RpcHost, config.RpcAuth, config.RpcZmq, config.WalletAddress),
}
}

View File

@@ -0,0 +1,71 @@
package btc
import (
"encoding/hex"
"fmt"
"log"
zmq "github.com/pebbe/zmq4"
)
const (
TOPIC_RAWTX = "rawtx"
TOPIC_RAWBLOCK = "rawblock"
TOPIC_HASHBLOCK = "hashblock"
)
type Zmq struct {
subscriber *zmq.Socket
}
func (q *Zmq) Connect(address string) error {
subscriber, err := zmq.NewSocket(zmq.SUB)
if err != nil {
return fmt.Errorf("error creating ZeroMQ subscriber: %w", err)
}
if err := subscriber.Connect("tcp://" + address); err != nil {
return fmt.Errorf("error connecting to ZeroMQ socket: %w", err)
}
if err := subscriber.SetSubscribe(TOPIC_HASHBLOCK); err != nil {
return fmt.Errorf("subscriber.SetSubscribe: %w", err)
}
q.subscriber = subscriber
log.Println("subsribed to: " + address)
return nil
}
func (q *Zmq) Close() error {
return q.subscriber.Close()
}
func (q *Zmq) Listen(blocksChan chan<- string) {
if q.subscriber == nil {
log.Fatalln("subscriber cannot be nil")
return
}
log.Println("Listening for new blocks...")
for {
msgParts, err := q.subscriber.RecvMessageBytes(0)
if err != nil {
log.Println("Error receiving message:", err)
continue
}
if len(msgParts) < 2 {
log.Println("Received message part is too short:", msgParts)
continue
}
topic := string(msgParts[0])
switch topic {
case TOPIC_HASHBLOCK:
blockHash := hex.EncodeToString(msgParts[1])
blocksChan <- blockHash
case TOPIC_RAWTX:
// TODO: do something with raw tx
continue
}
}
}

View File

@@ -0,0 +1,215 @@
package mail
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/smtp"
"os"
"strings"
"time"
"golang.org/x/exp/slices"
)
const (
mime = "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n"
okSubject = "Proof-of-Evidence record successful"
failSubject = "Proof-of-Evidence record failed"
templateError = "errror.html"
templateClientConfirm = "client_confirm.html"
templateProviderConfirm = "provider_confirm.html"
)
type MailService struct {
auth smtp.Auth
host string
port string
from string
templatesDir string
tlsconfig *tls.Config
}
type SendOK struct {
Price float64
ExplorerUrl string
TxID string
BlockHash string
DocHash string
To string
}
type MailServiceConfig struct {
Auth smtp.Auth
Host string
Port string
From string // Sender email address
TemplatesDir string // Should end with slash '/'
}
var validCommonNames = []string{"ISRG Root X1", "R3", "DST Root CA X3"}
func NewMailService(config MailServiceConfig) *MailService {
return &MailService{
auth: config.Auth,
host: config.Host,
port: config.Port,
from: config.From,
templatesDir: config.TemplatesDir,
tlsconfig: &tls.Config{
InsecureSkipVerify: true,
ServerName: config.Host,
VerifyConnection: func(cs tls.ConnectionState) error {
// // Check the server's common name
// for _, cert := range cs.PeerCertificates {
// log.Println("cert.DNSNames", cert.DNSNames)
// if err := cert.VerifyHostname(config.Host); err != nil {
// return fmt.Errorf("invalid common name: %w", err)
// }
// }
// Check the certificate chain
opts := x509.VerifyOptions{
Intermediates: x509.NewCertPool(),
}
for _, cert := range cs.PeerCertificates[1:] {
opts.Intermediates.AddCert(cert)
}
_, err := cs.PeerCertificates[0].Verify(opts)
if err != nil {
return fmt.Errorf("invalid certificate chain: %w", err)
}
// Iterate over the certificates again to perform custom checks
for _, cert := range cs.PeerCertificates {
// Add your own custom checks here...
if time.Now().After(cert.NotAfter) {
return fmt.Errorf("certificate has expired")
}
if time.Now().Add(30 * 24 * time.Hour).After(cert.NotAfter) {
return fmt.Errorf("certificate will expire within 30 days")
}
if !slices.Contains(validCommonNames, cert.Issuer.CommonName) {
return fmt.Errorf("certificate is not issued by a trusted CA")
}
// log.Println("cert.ExtKeyUsage", cert.ExtKeyUsage)
// if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 || len(cert.ExtKeyUsage) == 0 || !slices.Contains(cert.ExtKeyUsage, x509.ExtKeyUsageServerAuth) {
// log.Printf("%+v", cert)
// return fmt.Errorf("certificate cannot be used for server authentication")
// }
if cert.PublicKeyAlgorithm != x509.RSA {
return fmt.Errorf("unsupported public key algorithm")
}
}
return nil
},
},
}
}
func (m *MailService) SendProviderConfirm(data SendOK) error {
bts, err := os.ReadFile(m.templatesDir + templateProviderConfirm)
if err != nil {
return fmt.Errorf("os.ReadFile: %s", err)
}
template := strings.Replace(string(bts), "{{explorer_url}}", data.ExplorerUrl, -1)
template = strings.Replace(template, "{{tx_id}}", data.TxID, -1)
template = strings.Replace(template, "{{block_hash}}", data.BlockHash, -1)
template = strings.Replace(template, "{{doc_hash}}", data.DocHash, -1)
template = strings.Replace(template, "{{support_email}}", m.from, -1)
msg := []byte(m.messageWithHeaders(okSubject, data.To, template))
return m.send(data.To, msg)
}
func (m *MailService) SendClientConfirm(data SendOK) error {
bts, err := os.ReadFile(m.templatesDir + templateClientConfirm)
if err != nil {
return fmt.Errorf("os.ReadFile: %s", err)
}
template := strings.Replace(string(bts), "{{explorer_url}}", data.ExplorerUrl, -1)
template = strings.Replace(template, "{{tx_id}}", data.TxID, -1)
template = strings.Replace(template, "{{block_hash}}", data.BlockHash, -1)
template = strings.Replace(template, "{{doc_hash}}", data.DocHash, -1)
template = strings.Replace(template, "{{support_email}}", m.from, -1)
msg := []byte(m.messageWithHeaders(okSubject, data.To, template))
return m.send(data.To, msg)
}
func (m *MailService) SendFail(data SendOK) error {
//templateError
bts, err := os.ReadFile(m.templatesDir + templateError)
if err != nil {
return fmt.Errorf("os.ReadFile: %s", err)
}
template := strings.Replace(string(bts), "{{explorer_url}}", data.ExplorerUrl, -1)
template = strings.Replace(template, "{{tx_id}}", data.TxID, -1)
template = strings.Replace(template, "{{block_hash}}", data.BlockHash, -1)
template = strings.Replace(template, "{{doc_hash}}", data.DocHash, -1)
template = strings.Replace(template, "{{support_email}}", m.from, -1)
// TODO: Alert client too
msg := []byte(m.messageWithHeaders(okSubject, data.To, template))
return m.send(data.To, msg)
}
func (m *MailService) send(to string, msg []byte) error {
c, err := smtp.Dial(m.host + ":" + m.port)
if err != nil {
return fmt.Errorf("DIAL: %s", err)
}
if err = c.StartTLS(m.tlsconfig); err != nil {
return fmt.Errorf("c.StartTLS: %s", err)
}
// Auth
if err = c.Auth(m.auth); err != nil {
return fmt.Errorf("c.Auth: %s", err)
}
// To && From
if err = c.Mail(m.from); err != nil {
return fmt.Errorf("c.Mail: %s", err)
}
if err = c.Rcpt(to); err != nil {
return fmt.Errorf("c.Rcpt: %s", err)
}
// Data
w, err := c.Data()
if err != nil {
return fmt.Errorf("c.Data: %s", err)
}
_, err = w.Write(msg)
if err != nil {
return fmt.Errorf("w.Write: %s", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("w.Close: %s", err)
}
if err = c.Quit(); err != nil {
return fmt.Errorf("w.Quit: %s", err)
}
return nil
}
func (m *MailService) messageWithHeaders(subject, to, body string) string {
headers := make(map[string]string)
headers["From"] = m.from
headers["To"] = to
headers["Subject"] = subject
headers["MIME-Version"] = "1.0"
message := ""
for k, v := range headers {
message += fmt.Sprintf("%s: %s\r\n", k, v)
}
message += "Content-Type: text/html; charset=utf-8\r\n" + body
return message
}

View File

@@ -0,0 +1,52 @@
package mail
import (
"net/smtp"
"testing"
"gitea.urkob.com/urko/btc-pay-checker/kit"
"gitea.urkob.com/urko/btc-pay-checker/kit/cfg"
"github.com/stretchr/testify/require"
)
var (
mailSrv *MailService
config *cfg.Config
)
func init() {
config = cfg.NewConfig(kit.RootDir() + "/.test.env")
mailSrv = NewMailService(
MailServiceConfig{
Auth: smtp.PlainAuth("", config.MailUser, config.MailPassword, config.MailHost),
Host: config.MailHost,
Port: config.MailPort,
From: config.MailFrom,
TemplatesDir: config.MailTemplatesDir,
},
)
}
func Test_mailService_SendOK(t *testing.T) {
dto := SendOK{
Price: 10.2,
ExplorerUrl: "test",
TxID: "test-hash",
To: config.MailTo,
}
err := mailSrv.SendClientConfirm(dto)
require.NoError(t, err)
}
func Test_mailService_SendConfirm(t *testing.T) {
dto := SendOK{
ExplorerUrl: "test",
TxID: "test-hash",
BlockHash: "block-hash",
To: config.MailTo,
}
err := mailSrv.SendProviderConfirm(dto)
require.NoError(t, err)
}

View File

@@ -0,0 +1 @@
TODO:

View File

@@ -0,0 +1 @@
TODO:

View File

@@ -0,0 +1 @@
TODO:

View File

@@ -0,0 +1,54 @@
package services
import (
"context"
"time"
"gitea.urkob.com/urko/btc-pay-checker/internal/domain"
"gitea.urkob.com/urko/btc-pay-checker/internal/platform/mongodb/order"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const defaultExpirationTime = time.Minute * 30
type Order struct {
repo *order.Repo
expiration time.Duration
}
func NewOrder(repo *order.Repo) *Order {
return &Order{
repo: repo,
expiration: defaultExpirationTime,
}
}
func (o *Order) WithExpiration(expiration time.Duration) *Order {
o.expiration = expiration
return o
}
func (o *Order) NewOrder(ctx context.Context, OrderID string, ClientID string, amount float64) (*domain.Order, error) {
order := domain.Order{
ID: primitive.NewObjectID(),
OrderID: OrderID,
ClientID: ClientID,
Amount: amount,
PaidAt: time.Time{},
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(o.expiration),
}
_, err := o.repo.Insert(ctx, &order)
if err != nil {
return nil, err
}
return &order, err
}
func (o *Order) FromAmount(ctx context.Context, amount float64, timestamp time.Time) (*domain.Order, error) {
return o.repo.FromAmount(ctx, amount, timestamp)
}
func (o *Order) OrderCompleted(ctx context.Context, order *domain.Order) error {
return o.repo.OrderCompleted(ctx, order)
}

View File

@@ -0,0 +1,143 @@
package price
import (
"encoding/json"
"fmt"
"io"
"net/http"
"gitea.urkob.com/urko/btc-pay-checker/internal/domain"
"gitea.urkob.com/urko/btc-pay-checker/kit"
)
type CoinPriceResponse struct {
Coins map[string]map[string]interface{} `json:"coins"`
}
type PriceConversor struct {
apiUrl string
dollarRateApi string
client *http.Client
}
func NewPriceConversor(apiUrl, dollarRateApi string) *PriceConversor {
return &PriceConversor{
apiUrl: apiUrl,
client: &http.Client{},
dollarRateApi: dollarRateApi,
}
}
type usdConversorResponse struct {
Result string `json:"result"`
Provider string `json:"provider"`
Documentation string `json:"documentation"`
TermsOfUse string `json:"terms_of_use"`
TimeLastUpdateUnix int `json:"time_last_update_unix"`
TimeLastUpdateUtc string `json:"time_last_update_utc"`
TimeNextUpdateUnix int `json:"time_next_update_unix"`
TimeNextUpdateUtc string `json:"time_next_update_utc"`
TimeEolUnix int `json:"time_eol_unix"`
BaseCode string `json:"base_code"`
Rates map[string]float64 `json:"rates"`
}
func (p *PriceConversor) USDTo(c domain.Coin) (float64, error) {
usd := 0.0
reqURL := fmt.Sprintf(p.dollarRateApi)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return usd, fmt.Errorf("http.NewRequest: %s", err)
}
kit.WithJSONHeaders(req)
resp, err := p.client.Do(req)
if err != nil {
return usd, fmt.Errorf("client.Do: %s", err)
}
bts, err := io.ReadAll(resp.Body)
if err != nil {
return usd, fmt.Errorf("iokit.ReadAll: %s", err)
}
if resp.StatusCode != http.StatusOK {
return usd, fmt.Errorf("error %d: %s", resp.StatusCode, string(bts))
}
var respBody usdConversorResponse
if err = json.Unmarshal(bts, &respBody); err != nil {
return usd, fmt.Errorf("json.Unmarshal: %s", err)
}
v, ok := respBody.Rates[string(c)]
if !ok {
return usd, fmt.Errorf("coin isn't found")
}
return v, nil
}
func (p *PriceConversor) USD(c domain.Coin) (float64, error) {
usd := 0.0
reqURL := fmt.Sprintf(p.apiUrl+"/prices/current/%s", c)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return usd, fmt.Errorf("http.NewRequest: %s", err)
}
kit.WithJSONHeaders(req)
resp, err := p.client.Do(req)
if err != nil {
return usd, fmt.Errorf("client.Do: %s", err)
}
bts, err := io.ReadAll(resp.Body)
if err != nil {
return usd, fmt.Errorf("iokit.ReadAll: %s", err)
}
if resp.StatusCode != http.StatusOK {
return usd, fmt.Errorf("error %d: %s", resp.StatusCode, string(bts))
}
var respBody CoinPriceResponse
if err = json.Unmarshal(bts, &respBody); err != nil {
return usd, fmt.Errorf("json.Unmarshal: %s", err)
}
if _, hasKey := respBody.Coins[string(c)]; !hasKey {
return usd, fmt.Errorf("coin isn't found")
}
if _, hasKey := respBody.Coins[string(c)]["price"]; !hasKey {
return usd, fmt.Errorf("coin price isn't found")
}
usd, ok := respBody.Coins[string(c)]["price"].(float64)
if !ok {
return usd, fmt.Errorf("cannot cast price to float64")
}
return usd, nil
}
func (p *PriceConversor) UsdToBtc(usdAmount float64) (float64, error) {
usd, err := p.USD(domain.CoinBTC)
if err != nil {
return usdAmount, fmt.Errorf("USDToBtc: %s", err)
}
return usdAmount / usd, nil
}
func (p *PriceConversor) BtcToUsd(btcAmount float64) (float64, error) {
usd, err := p.USD(domain.CoinBTC)
if err != nil {
return btcAmount, fmt.Errorf("USDToBtc: %s", err)
}
return btcAmount * usd, nil
}

View File

@@ -0,0 +1,52 @@
package price
import (
"log"
"testing"
"gitea.urkob.com/urko/btc-pay-checker/internal/domain"
"gitea.urkob.com/urko/btc-pay-checker/kit"
"gitea.urkob.com/urko/btc-pay-checker/kit/cfg"
"github.com/stretchr/testify/require"
)
func TestConversor(t *testing.T) {
config := cfg.NewConfig(kit.RootDir() + "/.test.env")
p := NewPriceConversor(config.ConversorApi, config.DollarRateApi)
usd, err := p.USD(domain.CoinBTC)
require.NoError(t, err)
log.Println("$", usd)
require.Greater(t, usd, 0.0)
usd, err = p.USD(domain.CoinBTC)
require.NoError(t, err)
log.Println("$", usd)
require.Greater(t, usd, 0.0)
usdAmount := 100.0
btcPrice, err := p.UsdToBtc(usdAmount)
require.NoError(t, err)
log.Println("btcPrice", btcPrice)
require.Equal(t, btcPrice, usdAmount/usd)
usd, err = p.USD(domain.CoinBTC)
require.NoError(t, err)
log.Println("$", usd)
require.Greater(t, usd, 0.0)
btcAmount := 0.0001
usdPrice, err := p.BtcToUsd(btcAmount)
require.NoError(t, err)
log.Println("usdPrice", usdPrice)
require.Equal(t, usdPrice, btcAmount*usd)
euroRate, err := p.USDTo(domain.FiatCurrencyEuro)
require.NoError(t, err)
require.Greater(t, euroRate, 0.0)
log.Println("euroRate", euroRate)
usdAmount = 150.0
converted := usdAmount * euroRate
require.Greater(t, converted, 0.0)
require.Equal(t, usdAmount, converted/euroRate)
}