feat: set up project

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

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
.env
.test.env
.vscode
coverage
.notes

View File

@ -1,12 +1,10 @@
---- Definitions ----
license means right to use
author means who had initial idea, who research about jurisprudence and the who who started, in this cas : Urko: Bein.
author means who had initial idea, who did research and who started this project, in this case : Urko: Bein.
contributors means every man who has helped to improve this software
Everybody is invited to contribute to improve this project and the main idea.
This idea which is to help the community to present a prima facie, https://www.law.cornell.edu/wex/prima_facie,
proof of evidence from unmodified documents based on hash algorythm
published in the blockchain to be a public constructive notice: See https://www.law.cornell.edu/wex/constructive_notice
This idea which is to help the community to have a bitcoin pay checker for their ecommerce platforms
For the benefit of all men and women by the grace of YAHWEH!

13
Makefile Normal file
View File

@ -0,0 +1,13 @@
COVERAGE_DIR=coverage
lint:
golangci-lint run ./...
goreportcard:
goreportcard-cli -v
test:
go test ./...
test-coverage:
rm -rf ${COVERAGE_DIR}
mkdir ${COVERAGE_DIR}
go test -v -coverprofile ${COVERAGE_DIR}/cover.out ./...
go tool cover -html ${COVERAGE_DIR}/cover.out -o ${COVERAGE_DIR}/cover.html

47
README.md Normal file
View File

@ -0,0 +1,47 @@
# Bitcoin Payment Checker
Bitcoin Payment Checker is a Go application that allows clients to pay for orders using Bitcoin. The application calculates the Bitcoin equivalent of the order amount, provides a wallet address for payment, and tracks the payment status.
## Application Structure
The application is structured into several packages:
- `handler`: Contains the HTTP handlers for the application. The `OrderHandler` is responsible for processing new orders and calculating the Bitcoin price.
- `services`: Contains various services used by the application, such as the `Order` service for managing orders and the `PriceConversor` service for converting USD to Bitcoin.
- `main`: The entry point of the application. It sets up the database connection, initializes the services, and starts the HTTP server.
## Key Files
- `handler/order.go`: Contains the `OrderHandler` which processes new orders. It calculates the Bitcoin price of the order, stores the order in the database, and renders an HTML page with the order details.
- `main.go`: The entry point of the application. It sets up the database connection, initializes the services, and starts the HTTP server.
## Running the Application
To run the application, you need to have Go installed. Then, you can run the application using the `go run` command:
```bash
go run main.go
```
This will start the application and listen for HTTP requests on the configured port.
## Environment Variables
The application uses the following environment variables:
```
PAY_CHECKER_ENV: The environment the application is running in. If set to "dev", the application will load configuration from a .env file.
DB_ADDRESS: The address of the MongoDB database.
DB_NAME: The name of the MongoDB database.
ORDERS_COLLECTION: The name of the MongoDB collection for orders.
CONVERSOR_API: The API used for converting USD to Bitcoin.
RPC_HOST, RPC_AUTH, RPC_ZMQ, WALLET_ADDRESS: Configuration for the Bitcoin service.
MAIL_USER, MAIL_PASSWORD, MAIL_HOST, MAIL_PORT, MAIL_FROM, MAIL_TEMPLATES_DIR: Configuration for the mail service.
```
## Dependencies
The application uses several external packages:
gofiber/fiber/v2: For building the HTTP server and handling requests.
go.mongodb.org/mongo-driver/mongo: For connecting to and interacting with MongoDB.
net/smtp: For sending emails.

108
cmd/http/server/main.go Normal file
View File

@ -0,0 +1,108 @@
package main
import (
"context"
"fmt"
"log"
"net/smtp"
"os"
"os/signal"
"strings"
"syscall"
"time"
"gitea.urkob.com/urko/btc-pay-checker/internal/api"
"gitea.urkob.com/urko/btc-pay-checker/internal/platform/mongodb/order"
"gitea.urkob.com/urko/btc-pay-checker/internal/services"
"gitea.urkob.com/urko/btc-pay-checker/internal/services/btc"
"gitea.urkob.com/urko/btc-pay-checker/internal/services/mail"
"gitea.urkob.com/urko/btc-pay-checker/internal/services/price"
"gitea.urkob.com/urko/btc-pay-checker/kit/cfg"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func main() {
envFile := ""
if os.Getenv("PAY_CHECKER_ENV") == "dev" {
envFile = ".env"
}
config := cfg.NewConfig(envFile)
log.SetFlags(log.Lmicroseconds)
if config.LogFile {
logFileName := fmt.Sprintf("%s.txt", time.Now().Format(strings.ReplaceAll(time.RFC1123Z, ":", "_")))
f, err := os.OpenFile(logFileName, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
if err != nil {
log.Fatal(err)
}
defer f.Close()
log.SetOutput(f)
}
dbOpts := options.Client()
dbOpts.ApplyURI(config.DbAddress)
ctx, cancel := context.WithCancel(signalContext(context.Background()))
defer cancel()
client, err := mongo.Connect(ctx, dbOpts)
if err != nil {
panic(fmt.Errorf("mongo.NewClient: %w", err))
}
log.Println("mongodb client is connected")
ordersCollection := client.
Database(config.DbName).
Collection(config.OrdersCollection)
orderRepo := order.NewRepo(ordersCollection)
orderSrv := services.NewOrder(orderRepo)
priceSrv := price.NewPriceConversor(config.ConversorApi, config.ConversorApi)
btcSrv := btc.NewBitcoinService(config.RpcHost, config.RpcAuth, config.RpcZmq, config.WalletAddress).WithTestnet()
mailSrv := mail.NewMailService(
mail.MailServiceConfig{
Auth: smtp.PlainAuth("", config.MailUser, config.MailPassword, config.MailHost),
Host: config.MailHost,
Port: config.MailPort,
From: config.MailFrom,
TemplatesDir: config.MailTemplatesDir,
},
)
restServer := api.NewRestServer(config, orderSrv, btcSrv, priceSrv, mailSrv)
go func() {
if err = restServer.Start(ctx, config.ApiPort, config.Views); err != nil {
panic(fmt.Errorf("restServer.Start: %w", err))
}
}()
<-ctx.Done()
log.Println("on shutdown")
if restServer != nil {
if err := restServer.Shutdown(); err != nil {
panic(fmt.Errorf("restServer.Shutdown: %w", err))
}
}
log.Println("gracefully shutdown")
}
func signalContext(ctx context.Context) context.Context {
ctx, cancel := context.WithCancel(ctx)
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Println("listening for shutdown signal")
<-sigs
log.Println("shutdown signal received")
signal.Stop(sigs)
close(sigs)
cancel()
}()
return ctx
}

52
cmd/http/views/error.hbs Normal file
View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f4f4f4;
}
.container {
width: 80%;
margin: auto;
overflow: hidden;
}
.alert {
padding: 20px;
background-color: #f9edbe;
color: #856404;
border: 1px solid #ffeeba;
border-radius: 5px;
margin: 20px 0;
}
.details {
background-color: #fff;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
}
.details h2 {
margin-top: 0;
}
.details p {
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="alert">
<p><strong>Unexpected error: {{message}}</strong></p>
</div>
</div>
</body>
</html>

104
cmd/http/views/order.hbs Normal file
View File

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f4f4f4;
}
.container {
width: 80%;
margin: auto;
overflow: hidden;
}
.alert {
padding: 20px;
background-color: #f9edbe;
color: #856404;
border: 1px solid #ffeeba;
border-radius: 5px;
margin: 20px 0;
}
.details {
background-color: #fff;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
}
.details h2 {
margin-top: 0;
}
.details p {
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="alert">
<h1>Bitcoin Payment</h1>
<p>Please send the exact amount of Bitcoin to the provided wallet address. Your order will be processed once the
transaction is confirmed.</p>
</div>
<p id="countdown"><strong>Time Remaining:</strong></p>
<div class="details">
<h2>Order Details</h2>
<p><strong>Order ID:</strong> {{order_id}}</p>
<p><strong>Amount:</strong> {{amount}} BTC</p>
<p><strong>Wallet Address:</strong> {{wallet_address}}</p>
<p id="dueDate"><strong>Payment Due:</strong></p>
<p id="countdown"><strong>Time Remaining:</strong> </p>
</div>
</div>
<script>
// Get the expires_at time from your server and convert it to a JavaScript Date object
const dueDate = new Date("{{expires_at}}");
// Format the date and time
const formattedDate = dueDate.toLocaleDateString() + " " + dueDate.toLocaleTimeString();
// Display the result in the element with id="dueDate"
document.getElementById("dueDate").innerHTML = "<strong>Payment Due:</strong> " + formattedDate;
// Get the expires_at time from your server and convert it to a JavaScript Date object
const countDownDate = new Date("{{expires_at}}").getTime();
// Update the countdown every 1 second
const countdownInterval = setInterval(function () {
// Get today's date and time
const now = new Date().getTime();
// Find the distance between now and the count down date
const distance = countDownDate - now;
// Time calculations for days, hours, minutes and seconds
const days = Math.floor(distance / (1000 * 60 * 60 * 24));
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
// Display the result in the element with id="countdown"
// document.getElementById("countdown").innerHTML = "<strong>Time Remaining:</strong> " + days + "d " + hours + "h " + minutes + "m " + seconds + "s ";
document.getElementById("countdown").innerHTML = "<strong>Time Remaining:</strong> " + minutes + "m " + seconds + "s ";
// If the countdown is finished, write some text
if (distance < 0) {
clearInterval(countdownInterval);
document.getElementById("countdown").innerHTML = "EXPIRED";
}
}, 1000);
</script>
</body>
</html>

47
go.mod Normal file
View File

@ -0,0 +1,47 @@
module gitea.urkob.com/urko/btc-pay-checker
go 1.20
require (
gitea.urkob.com/urko/go-root-dir v0.0.0-20230311113851-2f6d4355888a
github.com/docker/go-units v0.5.0
github.com/gofiber/fiber/v2 v2.48.0
github.com/gofiber/template/handlebars/v2 v2.1.4
github.com/joho/godotenv v1.5.1
github.com/kelseyhightower/envconfig v1.4.0
github.com/pebbe/zmq4 v1.2.10
github.com/stretchr/testify v1.8.4
go.mongodb.org/mongo-driver v1.12.0
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
)
require (
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/aymerick/raymond v2.0.2+incompatible // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gofiber/template v1.8.2 // indirect
github.com/gofiber/utils v1.1.0 // indirect
github.com/golang/snappy v0.0.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/klauspost/compress v1.16.5 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
github.com/philhofer/fwd v1.1.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/tinylib/msgp v1.1.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.48.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
golang.org/x/crypto v0.7.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/text v0.8.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

121
go.sum Normal file
View File

@ -0,0 +1,121 @@
gitea.urkob.com/urko/go-root-dir v0.0.0-20230311113851-2f6d4355888a h1:s73cd3bRR6v0LGiBei841iIolbBJN2tbkUwN54X9vVg=
gitea.urkob.com/urko/go-root-dir v0.0.0-20230311113851-2f6d4355888a/go.mod h1:mU9nRHl70tBhJFbgKotpoXMV+s0wx+1uJ988p4oEpSo=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/aymerick/raymond v2.0.2+incompatible h1:VEp3GpgdAnv9B2GFyTvqgcKvY+mfKMjPOA3SbKLtnU0=
github.com/aymerick/raymond v2.0.2+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
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/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/gofiber/fiber/v2 v2.48.0 h1:cRVMCb9aUJDsyHxGFLwz/sGzDggdailZZyptU9F9cU0=
github.com/gofiber/fiber/v2 v2.48.0/go.mod h1:xqJgfqrc23FJuqGOW6DVgi3HyZEm2Mn9pRqUb2kHSX8=
github.com/gofiber/template v1.8.2 h1:PIv9s/7Uq6m+Fm2MDNd20pAFFKt5wWs7ZBd8iV9pWwk=
github.com/gofiber/template v1.8.2/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
github.com/gofiber/template/handlebars/v2 v2.1.4 h1:m/GwEnzv5bpifOg9BrpCIzkhD2GU10E5oZjI8NdDbYY=
github.com/gofiber/template/handlebars/v2 v2.1.4/go.mod h1:bb6ip6ZEgBqKSdZcbnFLlfL8PcCRslnX6WgpcxVBiTE=
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/pebbe/zmq4 v1.2.10 h1:wQkqRZ3CZeABIeidr3e8uQZMMH5YAykA/WN0L5zkd1c=
github.com/pebbe/zmq4 v1.2.10/go.mod h1:nqnPueOapVhE2wItZ0uOErngczsJdLOGkebMxaO8r48=
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
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/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.48.0 h1:oJWvHb9BIZToTQS3MuQ2R3bJZiNSa2KiNdeI8A+79Tc=
github.com/valyala/fasthttp v1.48.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.12.0 h1:aPx33jmn/rQuJXPQLZQ8NtfPQG8CaqgLThFtqRb0PiE=
go.mongodb.org/mongo-driver v1.12.0/go.mod h1:AZkxhPnFJUoH7kZlFkVKucV20K387miPfm7oimrSmK0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,23 @@
package handler
import (
"log"
"github.com/gofiber/fiber/v2"
)
func RenderError(c *fiber.Ctx, err error, message string) error {
if err != nil {
log.Printf("renderError: %s\n", err)
}
return c.Render("error", fiber.Map{
"message": message,
})
}
func JSONError(c *fiber.Ctx, status int, err error, message string) error {
if err != nil {
log.Printf("JSONError: %s\n", err)
}
return c.Status(status).SendString("error: " + message)
}

View File

@ -0,0 +1,58 @@
package handler
import (
"fmt"
"time"
"gitea.urkob.com/urko/btc-pay-checker/internal/domain"
"gitea.urkob.com/urko/btc-pay-checker/internal/services"
"gitea.urkob.com/urko/btc-pay-checker/internal/services/price"
"github.com/gofiber/fiber/v2"
)
type OrderHandler struct {
walletAddress string
orderSrv *services.Order
conversor *price.PriceConversor
}
func NewOrderHandler(walletAddress string, orderSrv *services.Order, conversor *price.PriceConversor) *OrderHandler {
return &OrderHandler{
walletAddress: walletAddress,
orderSrv: orderSrv,
conversor: conversor,
}
}
type orderReq struct {
OrderID string `json:"order_id"`
ClientID string `json:"client_id"`
Amount float64 `json:"amount"`
Currency domain.FiatCurrency `json:"currency"`
}
func (hdl *OrderHandler) Post(c *fiber.Ctx) error {
req := orderReq{}
if err := c.BodyParser(&req); err != nil {
return RenderError(c, fmt.Errorf("id is empty"), "")
}
btcAmount, err := hdl.conversor.UsdToBtc(req.Amount)
if err != nil {
return RenderError(c, fmt.Errorf("hdl.conversor.UsdToBtc %w", err), "")
}
order, err := hdl.orderSrv.NewOrder(c.Context(), req.OrderID, req.ClientID, btcAmount)
if err != nil {
return RenderError(c, fmt.Errorf("hdl.orderSrv.NewOrder %w", err), "")
}
return c.Render("order",
fiber.Map{
"order_id": order.ID.Hex(),
"amount": btcAmount,
"wallet_address": hdl.walletAddress,
"expires_at": order.ExpiresAt.Format(time.RFC3339),
},
)
}

152
internal/api/server.go Normal file
View File

@ -0,0 +1,152 @@
package api
import (
"context"
"log"
"time"
"github.com/docker/go-units"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/template/handlebars/v2"
"gitea.urkob.com/urko/btc-pay-checker/internal/api/handler"
"gitea.urkob.com/urko/btc-pay-checker/internal/domain"
"gitea.urkob.com/urko/btc-pay-checker/internal/services"
"gitea.urkob.com/urko/btc-pay-checker/internal/services/btc"
"gitea.urkob.com/urko/btc-pay-checker/internal/services/mail"
"gitea.urkob.com/urko/btc-pay-checker/internal/services/price"
"gitea.urkob.com/urko/btc-pay-checker/kit/cfg"
)
const (
MAX_FILE_SIZE = 25
MAX_FILE_SIZE_MiB = MAX_FILE_SIZE * units.MiB
)
type RestServer struct {
app *fiber.App
config *cfg.Config
btcService *btc.BitcoinService
orderSrv *services.Order
mailSrv *mail.MailService
priceSrv *price.PriceConversor
}
func NewRestServer(
config *cfg.Config,
orderSrv *services.Order,
btcService *btc.BitcoinService,
priceSrv *price.PriceConversor,
mailSrv *mail.MailService,
) *RestServer {
return &RestServer{
config: config,
orderSrv: orderSrv,
btcService: btcService,
priceSrv: priceSrv,
mailSrv: mailSrv,
}
}
func (s *RestServer) Start(ctx context.Context, apiPort, views string) error {
engine := handlebars.New(views, ".hbs")
s.app = fiber.New(fiber.Config{
Views: engine,
BodyLimit: MAX_FILE_SIZE_MiB,
})
s.app.Use(limiter.New(limiter.Config{
Max: 5,
Expiration: 1 * time.Hour,
LimiterMiddleware: limiter.SlidingWindow{},
}))
s.app.Use(cors.New(cors.Config{
AllowMethods: "GET,POST,OPTIONS",
AllowOrigins: "*",
AllowHeaders: "Origin, Accept, Content-Type, X-CSRF-Token, Authorization",
ExposeHeaders: "Origin",
}))
s.loadViews(views + "/images")
orderHandler := handler.NewOrderHandler(s.config.WalletAddress, s.orderSrv, s.priceSrv)
notifChan := make(chan domain.Notification)
go s.btcService.Notify(ctx, notifChan)
go s.onNotification(ctx, notifChan)
s.app.Post("/order", orderHandler.Post)
if err := s.app.Listen(":" + apiPort); err != nil {
log.Fatalln("app.Listen:", err)
return err
}
return nil
}
// onNotification Step
// 1- update the block where tx has been forged
// 2- send email to client alerting his block has been forged
// 3- to upload file into arweave
func (s *RestServer) onNotification(ctx context.Context, notifChan chan domain.Notification) {
for notif := range notifChan {
order, err := s.orderSrv.FromAmount(ctx, notif.Amount, notif.DoneAt)
if err != nil {
log.Println("error while retrieve transaction from forged block: ", err)
continue
}
order.Block = notif.BlockHash
order.Tx = notif.Tx
if err := s.orderSrv.OrderCompleted(ctx, order); err != nil {
log.Println("OrderCompleted:", err)
continue
}
// Send email to client and provider
if err := s.mailSrv.SendProviderConfirm(mail.SendOK{
TxID: order.Tx,
BlockHash: order.Block,
DocHash: "",
To: "doc.Email",
}); err != nil {
log.Println("error while send confirm email:", err)
continue
}
}
}
func (s *RestServer) loadViews(imagesDir string) {
s.app.Static("/images", imagesDir)
s.app.Get("/", func(c *fiber.Ctx) error {
return c.Render("upload", fiber.Map{})
})
s.app.Get("/error", func(c *fiber.Ctx) error {
message := c.Query("message")
return renderError(c, nil, message)
})
}
func renderError(c *fiber.Ctx, err error, message string) error {
if err != nil {
log.Printf("renderError: %s\n", err)
}
return c.Render("error", fiber.Map{
"message": message,
})
}
func (s *RestServer) Shutdown() error {
if err := s.app.Server().Shutdown(); err != nil {
log.Printf("app.Server().Shutdown(): %s\n", err)
return err
}
return nil
}

10
internal/domain/coin.go Normal file
View File

@ -0,0 +1,10 @@
package domain
type Coin string
const CoinBTC Coin = "coingecko:bitcoin"
type FiatCurrency string
const FiatCurrencyDollar = "USD"
const FiatCurrencyEuro = "EUR"

View File

@ -0,0 +1,10 @@
package domain
import "time"
type Notification struct {
BlockHash string
Tx string
Amount float64
DoneAt time.Time
}

19
internal/domain/order.go Normal file
View File

@ -0,0 +1,19 @@
package domain
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Order struct {
ID primitive.ObjectID `bson:"_id" json:"_id"`
OrderID string `json:"order_id"`
ClientID string `json:"client_id"`
Amount float64 `bson:"amount" json:"amount"`
Tx string `bson:"tx" json:"tx"`
Block string `bson:"block" json:"block"`
PaidAt time.Time `bson:"paid_at" json:"paid_at"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
}

View File

@ -0,0 +1,80 @@
package order
import (
"context"
"fmt"
"log"
"time"
"gitea.urkob.com/urko/btc-pay-checker/internal/domain"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
type Repo struct {
collection *mongo.Collection
}
func NewRepo(collection *mongo.Collection) *Repo {
return &Repo{
collection: collection,
}
}
func (repo *Repo) Insert(ctx context.Context, order *domain.Order) (primitive.ObjectID, error) {
res, err := repo.collection.InsertOne(ctx, order)
if err != nil {
return primitive.NilObjectID, fmt.Errorf("v.collection.InsertOne: %s", err)
}
documentID, ok := res.InsertedID.(primitive.ObjectID)
if !ok {
return primitive.NilObjectID, fmt.Errorf("res.InsertedID")
}
return documentID, nil
}
func (repo *Repo) FromAmount(ctx context.Context, amount float64, timestamp time.Time) (*domain.Order, error) {
order := &domain.Order{}
filter := bson.M{
"amount": amount,
"expires_at": bson.M{
"$gte": timestamp,
},
"paid_at": time.Time{},
}
count, err := repo.collection.CountDocuments(ctx, filter)
if err != nil {
return nil, fmt.Errorf("doc.Decode: %s", err)
}
if count <= 0 {
return nil, fmt.Errorf("order not found")
} else if count > 1 {
return nil, fmt.Errorf("multiple orders found: %d", count)
}
doc := repo.collection.FindOne(ctx, filter)
if err := doc.Decode(order); err != nil {
return nil, fmt.Errorf("doc.Decode: %s", err)
}
return order, nil
}
func (repo *Repo) OrderCompleted(ctx context.Context, order *domain.Order) error {
updateOpts, err := repo.collection.UpdateOne(ctx, bson.M{"_id": order.ID},
bson.M{
"tx": order.Tx,
"block": order.Block,
"paid_at": time.Now(),
},
)
if err != nil {
return fmt.Errorf("collection.UpdateOne: %s", err)
}
log.Printf("OrderCompleted update %+v\n", updateOpts)
return nil
}

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)
}

48
kit/cfg/config.go Normal file
View File

@ -0,0 +1,48 @@
package cfg
import (
"log"
"github.com/joho/godotenv"
"github.com/kelseyhightower/envconfig"
)
type Config struct {
LogFile bool `required:"false" split_words:"true"`
Host string `required:"true" split_words:"true"`
DbAddress string `required:"true" split_words:"true"`
DbName string `required:"true" split_words:"true"`
OrdersCollection string `required:"true" split_words:"true"`
RpcZmq string `required:"true" split_words:"true"`
RpcAuth string `required:"true" split_words:"true"`
RpcHost string `required:"true" split_words:"true"`
WalletAddress string `required:"true" split_words:"true"`
ApiPort string `required:"true" split_words:"true"`
Views string `required:"true" split_words:"true"`
ConversorApi string `required:"true" split_words:"true"`
DollarRateApi string `required:"true" split_words:"true"`
MailHost string `required:"true" split_words:"true"`
MailPort string `required:"true" split_words:"true"`
MailUser string `required:"true" split_words:"true"`
MailPassword string `required:"true" split_words:"true"`
MailFrom string `required:"true" split_words:"true"`
MailTemplatesDir string `required:"true" split_words:"true"`
MailTo string `required:"false" split_words:"true"`
}
func NewConfig(envFile string) *Config {
if envFile != "" {
err := godotenv.Load(envFile)
if err != nil {
log.Fatalln("godotenv.Load:", err)
}
}
cfg := &Config{}
err := envconfig.Process("", cfg)
if err != nil {
log.Fatalf("envconfig.Process: %s\n", err)
}
return cfg
}

16
kit/http.go Normal file
View File

@ -0,0 +1,16 @@
package kit
import "net/http"
const JsonHeader = "application/json"
func WithJSONHeaders(r *http.Request) {
r.Header.Add("Accept", JsonHeader)
r.Header.Add("Content-Type", JsonHeader)
}
func WithHeaders(r *http.Request, headers map[string]string) {
for k, v := range headers {
r.Header.Add(k, v)
}
}

9
kit/path.go Normal file
View File

@ -0,0 +1,9 @@
package kit
import (
root_dir "gitea.urkob.com/urko/go-root-dir"
)
func RootDir() string {
return root_dir.RootDir("btc-pay-checker")
}