diff --git a/.gitignore b/.gitignore index 627e865..61e074e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +.test.env .vscode coverage .notes diff --git a/LICENSE b/LICENSE index ca5a576..8e445b1 100644 --- a/LICENSE +++ b/LICENSE @@ -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! \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..152717e --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..51452c9 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/cmd/http/server/main.go b/cmd/http/server/main.go new file mode 100644 index 0000000..883e318 --- /dev/null +++ b/cmd/http/server/main.go @@ -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 +} diff --git a/cmd/http/views/error.hbs b/cmd/http/views/error.hbs new file mode 100644 index 0000000..ac82f99 --- /dev/null +++ b/cmd/http/views/error.hbs @@ -0,0 +1,52 @@ + + + + + + + + +
+
+

Unexpected error: {{message}}

+
+
+ + \ No newline at end of file diff --git a/cmd/http/views/order.hbs b/cmd/http/views/order.hbs new file mode 100644 index 0000000..2d6b0ed --- /dev/null +++ b/cmd/http/views/order.hbs @@ -0,0 +1,104 @@ + + + + + + + + +
+
+

Bitcoin Payment

+

Please send the exact amount of Bitcoin to the provided wallet address. Your order will be processed once the + transaction is confirmed.

+
+

Time Remaining:

+
+

Order Details

+

Order ID: {{order_id}}

+

Amount: {{amount}} BTC

+

Wallet Address: {{wallet_address}}

+

Payment Due:

+

Time Remaining:

+
+
+ + + + + \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f84f782 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5bee8f6 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/handler/helper.go b/internal/api/handler/helper.go new file mode 100644 index 0000000..5e23aaa --- /dev/null +++ b/internal/api/handler/helper.go @@ -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) +} diff --git a/internal/api/handler/payment.go b/internal/api/handler/payment.go new file mode 100644 index 0000000..0516dec --- /dev/null +++ b/internal/api/handler/payment.go @@ -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), + }, + ) +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..092cde6 --- /dev/null +++ b/internal/api/server.go @@ -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 +} diff --git a/internal/domain/coin.go b/internal/domain/coin.go new file mode 100644 index 0000000..d61e6d5 --- /dev/null +++ b/internal/domain/coin.go @@ -0,0 +1,10 @@ +package domain + +type Coin string + +const CoinBTC Coin = "coingecko:bitcoin" + +type FiatCurrency string + +const FiatCurrencyDollar = "USD" +const FiatCurrencyEuro = "EUR" diff --git a/internal/domain/notification.go b/internal/domain/notification.go new file mode 100644 index 0000000..f5ec8ec --- /dev/null +++ b/internal/domain/notification.go @@ -0,0 +1,10 @@ +package domain + +import "time" + +type Notification struct { + BlockHash string + Tx string + Amount float64 + DoneAt time.Time +} diff --git a/internal/domain/order.go b/internal/domain/order.go new file mode 100644 index 0000000..afc38db --- /dev/null +++ b/internal/domain/order.go @@ -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"` +} diff --git a/internal/platform/mongodb/order/repository.go b/internal/platform/mongodb/order/repository.go new file mode 100644 index 0000000..d9a2a55 --- /dev/null +++ b/internal/platform/mongodb/order/repository.go @@ -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 +} diff --git a/internal/services/btc/btc.go b/internal/services/btc/btc.go new file mode 100644 index 0000000..2c58d77 --- /dev/null +++ b/internal/services/btc/btc.go @@ -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 +} diff --git a/internal/services/btc/decode_raw_transaction.go b/internal/services/btc/decode_raw_transaction.go new file mode 100644 index 0000000..5328a16 --- /dev/null +++ b/internal/services/btc/decode_raw_transaction.go @@ -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 +} diff --git a/internal/services/btc/get_balance.go b/internal/services/btc/get_balance.go new file mode 100644 index 0000000..2c12402 --- /dev/null +++ b/internal/services/btc/get_balance.go @@ -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 +} diff --git a/internal/services/btc/get_balance_test.go b/internal/services/btc/get_balance_test.go new file mode 100644 index 0000000..f1a001f --- /dev/null +++ b/internal/services/btc/get_balance_test.go @@ -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) +} diff --git a/internal/services/btc/get_block.go b/internal/services/btc/get_block.go new file mode 100644 index 0000000..65ed7dc --- /dev/null +++ b/internal/services/btc/get_block.go @@ -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 +} diff --git a/internal/services/btc/get_block_test.go b/internal/services/btc/get_block_test.go new file mode 100644 index 0000000..cbffad5 --- /dev/null +++ b/internal/services/btc/get_block_test.go @@ -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) +} diff --git a/internal/services/btc/get_raw_transaction.go b/internal/services/btc/get_raw_transaction.go new file mode 100644 index 0000000..1b4b5b7 --- /dev/null +++ b/internal/services/btc/get_raw_transaction.go @@ -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 +} diff --git a/internal/services/btc/get_raw_transaction_test.go b/internal/services/btc/get_raw_transaction_test.go new file mode 100644 index 0000000..c51a3d3 --- /dev/null +++ b/internal/services/btc/get_raw_transaction_test.go @@ -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)) +} diff --git a/internal/services/btc/get_transaction.go b/internal/services/btc/get_transaction.go new file mode 100644 index 0000000..2435f43 --- /dev/null +++ b/internal/services/btc/get_transaction.go @@ -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 +} diff --git a/internal/services/btc/get_transaction_test.go b/internal/services/btc/get_transaction_test.go new file mode 100644 index 0000000..6b7599b --- /dev/null +++ b/internal/services/btc/get_transaction_test.go @@ -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) +} diff --git a/internal/services/btc/listaddressgroupings.go b/internal/services/btc/listaddressgroupings.go new file mode 100644 index 0000000..490e0eb --- /dev/null +++ b/internal/services/btc/listaddressgroupings.go @@ -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 +} diff --git a/internal/services/btc/listaddressgroupings_test.go b/internal/services/btc/listaddressgroupings_test.go new file mode 100644 index 0000000..8bc550d --- /dev/null +++ b/internal/services/btc/listaddressgroupings_test.go @@ -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) + +} diff --git a/internal/services/btc/notification.go b/internal/services/btc/notification.go new file mode 100644 index 0000000..96cafef --- /dev/null +++ b/internal/services/btc/notification.go @@ -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) + } +} diff --git a/internal/services/btc/rpc_request.go b/internal/services/btc/rpc_request.go new file mode 100644 index 0000000..26c84a2 --- /dev/null +++ b/internal/services/btc/rpc_request.go @@ -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 +} diff --git a/internal/services/btc/rpc_request_test.go b/internal/services/btc/rpc_request_test.go new file mode 100644 index 0000000..f7e7bb0 --- /dev/null +++ b/internal/services/btc/rpc_request_test.go @@ -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) +} diff --git a/internal/services/btc/rpc_response.go b/internal/services/btc/rpc_response.go new file mode 100644 index 0000000..21fc668 --- /dev/null +++ b/internal/services/btc/rpc_response.go @@ -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"` +} diff --git a/internal/services/btc/test_helper.go b/internal/services/btc/test_helper.go new file mode 100644 index 0000000..c6ed19f --- /dev/null +++ b/internal/services/btc/test_helper.go @@ -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), + } +} diff --git a/internal/services/btc/zmq.go b/internal/services/btc/zmq.go new file mode 100644 index 0000000..55bad9d --- /dev/null +++ b/internal/services/btc/zmq.go @@ -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 + } + } +} diff --git a/internal/services/mail/mail.go b/internal/services/mail/mail.go new file mode 100644 index 0000000..03f05dc --- /dev/null +++ b/internal/services/mail/mail.go @@ -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 +} diff --git a/internal/services/mail/mail_test.go b/internal/services/mail/mail_test.go new file mode 100644 index 0000000..2fd0294 --- /dev/null +++ b/internal/services/mail/mail_test.go @@ -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) +} diff --git a/internal/services/mail/templates/client_confirm.html b/internal/services/mail/templates/client_confirm.html new file mode 100644 index 0000000..17498c8 --- /dev/null +++ b/internal/services/mail/templates/client_confirm.html @@ -0,0 +1 @@ +TODO: \ No newline at end of file diff --git a/internal/services/mail/templates/error.html b/internal/services/mail/templates/error.html new file mode 100644 index 0000000..17498c8 --- /dev/null +++ b/internal/services/mail/templates/error.html @@ -0,0 +1 @@ +TODO: \ No newline at end of file diff --git a/internal/services/mail/templates/provider_confirm.html b/internal/services/mail/templates/provider_confirm.html new file mode 100644 index 0000000..17498c8 --- /dev/null +++ b/internal/services/mail/templates/provider_confirm.html @@ -0,0 +1 @@ +TODO: \ No newline at end of file diff --git a/internal/services/order.go b/internal/services/order.go new file mode 100644 index 0000000..2ea86a1 --- /dev/null +++ b/internal/services/order.go @@ -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) +} diff --git a/internal/services/price/conversor.go b/internal/services/price/conversor.go new file mode 100644 index 0000000..8d59e43 --- /dev/null +++ b/internal/services/price/conversor.go @@ -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 +} diff --git a/internal/services/price/conversor_test.go b/internal/services/price/conversor_test.go new file mode 100644 index 0000000..5e2c4d9 --- /dev/null +++ b/internal/services/price/conversor_test.go @@ -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) +} diff --git a/kit/cfg/config.go b/kit/cfg/config.go new file mode 100644 index 0000000..1baaca6 --- /dev/null +++ b/kit/cfg/config.go @@ -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 +} diff --git a/kit/http.go b/kit/http.go new file mode 100644 index 0000000..a47b199 --- /dev/null +++ b/kit/http.go @@ -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) + } +} diff --git a/kit/path.go b/kit/path.go new file mode 100644 index 0000000..7494c4d --- /dev/null +++ b/kit/path.go @@ -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") +}