aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSam Anthony <sam@samanthony.xyz>2026-06-30 16:03:45 -0230
committerSam Anthony <sam@samanthony.xyz>2026-06-30 16:03:45 -0230
commit7d0f3d56121feccfe84600a39c362ba8bfcaa042 (patch)
tree0e412db49a133af1020ae13d6a0f3930fa04ee06
downloadxmrpayclnt-7d0f3d56121feccfe84600a39c362ba8bfcaa042.zip
init
-rw-r--r--README6
-rw-r--r--callback.go164
-rw-r--r--callback_handler_test.go116
-rw-r--r--callback_test.go137
-rw-r--r--err.go17
-rw-r--r--go.mod13
-rw-r--r--go.sum15
-rw-r--r--xmrpayclnt.go95
8 files changed, 563 insertions, 0 deletions
diff --git a/README b/README
new file mode 100644
index 0000000..2da2335
--- /dev/null
+++ b/README
@@ -0,0 +1,6 @@
+MoneroPay Client
+
+This package provides a Go client library for the v2 MoneroPay HTTP API.
+
+https://moneropay.eu/
+https://gitlab.com/moneropay/moneropay
diff --git a/callback.go b/callback.go
new file mode 100644
index 0000000..e721250
--- /dev/null
+++ b/callback.go
@@ -0,0 +1,164 @@
+package xmrpayclnt
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "sync"
+
+ "gitlab.com/moneropay/moneropay/v2/pkg/model"
+)
+
+const callbackIdEntropy = 16 // 128 bits of entropy
+
+var callbackIdEnc = base64.URLEncoding
+
+type callbackId string
+
+func newCallbackId() callbackId {
+ raw := make([]byte, callbackIdEntropy)
+ rand.Read(raw)
+ return callbackId(callbackIdEnc.EncodeToString(raw))
+}
+
+// Callback receives information about a transaction.
+//
+// Whenever MoneroPay sends a POST request to the callback URL specified
+// in the POST /receive endpoint, the information is sent on C. C and
+// Complete are closed once the transfer's unlocked amount is >= the
+// requested amount, or if Close is called.
+type Callback struct {
+ id callbackId
+ url *url.URL
+ C <-chan model.CallbackResponse
+ Complete <-chan struct{}
+ in chan<- model.CallbackResponse // from http handler
+ kill chan<- struct{}
+}
+
+func newCallback(id callbackId, url *url.URL, done chan callbackId) Callback {
+ c := make(chan model.CallbackResponse)
+ complete := make(chan struct{})
+ in := make(chan model.CallbackResponse)
+ kill := make(chan struct{})
+
+ go func() {
+ defer func() {
+ done <- id // signal to handler
+ <-done // wait for handler to unlink us before closing input channel
+ close(in)
+
+ close(c)
+ close(complete)
+ }()
+
+ var info model.CallbackResponse
+ var fresh bool
+ for {
+ if fresh {
+ if info.Complete {
+ return
+ }
+ select {
+ case info = <-in:
+ case c <- info:
+ fresh = false
+ case <-kill:
+ return
+ }
+ } else {
+ select {
+ case info = <-in:
+ fresh = true
+ case <-kill:
+ return
+ }
+ }
+ }
+ }()
+
+ return Callback{id, url, c, complete, in, kill}
+}
+
+// Close causes the handler to stop listening for MoneroPay's callbacks
+// relating to this transaction and closes the Callback's channels.
+func (cb Callback) Close() { close(cb.kill) }
+
+func (cb Callback) URL() *url.URL { return cb.url }
+
+// CallbackHandler is a HTTP handler that listens for callback POST
+// requests from the MoneroPay server.
+type CallbackHandler struct {
+ base *url.URL
+ done chan callbackId
+
+ mu sync.Mutex // guards below
+ cbs map[callbackId]Callback
+}
+
+func NewCallbackHandler(base *url.URL) *CallbackHandler {
+ done := make(chan callbackId)
+ cbs := make(map[callbackId]Callback)
+ h := &CallbackHandler{base, done, sync.Mutex{}, cbs}
+ go func() {
+ for id := range done {
+ h.mu.Lock()
+ delete(cbs, id)
+ h.mu.Unlock()
+ done <- id // callback can continue cleaning up
+ }
+ }()
+ return h
+}
+
+// ServeHTTP implements http.Handler.
+func (h CallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // Decode callback body
+ var info model.CallbackResponse
+ dec := json.NewDecoder(r.Body)
+ if err := dec.Decode(&info); err != nil {
+ lg.Printf("%s: %v\n", r.URL, err)
+ return
+ }
+
+ // Send to callback goroutine
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ id := callbackId(r.URL.Query().Get("id"))
+ if cb, ok := h.cbs[id]; ok {
+ cb.in <- info
+ } else {
+ lg.Printf("%s: no such callback %q\n", r.URL, id)
+ }
+}
+
+// listen starts listening for callbacks for a new transaction.
+func (h CallbackHandler) listen() Callback {
+ id := newCallbackId()
+ url := callbackUrl(h.base, id)
+
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ cb := newCallback(id, url, h.done)
+ h.cbs[id] = cb
+ return cb
+}
+
+func callbackUrl(base *url.URL, id callbackId) *url.URL {
+ url := copyUrl(base)
+ q := url.Query()
+ q.Set("id", string(id))
+ url.RawQuery = q.Encode()
+ return url
+}
+
+func copyUrl(u *url.URL) *url.URL {
+ cp := *u
+ if u.User != nil {
+ ucp := *u.User
+ cp.User = &ucp
+ }
+ return &cp
+}
diff --git a/callback_handler_test.go b/callback_handler_test.go
new file mode 100644
index 0000000..50b4ab5
--- /dev/null
+++ b/callback_handler_test.go
@@ -0,0 +1,116 @@
+package xmrpayclnt_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "time"
+
+ "gitlab.com/moneropay/moneropay/v2/pkg/model"
+
+ xmrpay "git.samanthony.xyz/xmrpayclnt"
+)
+
+func ExampleCallbackHandler() {
+ // Mock the MoneroPay server's POST /receive endpoint for the
+ // sake of the example
+ xmrPaySrv := httptest.NewServer(mockReceivePostHandler{})
+ xmrPaySrvUrl, err := url.Parse(xmrPaySrv.URL)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Create a callback handler that will listen on
+ // http://localhost:57904/moneropay?abcxyz... for callbacks from
+ // the MoneroPay server
+ cbUrl, err := url.Parse("http://localhost:57904/moneropay")
+ if err != nil {
+ log.Fatal(err)
+ }
+ cbSrv := http.NewServeMux()
+ cbHlr := xmrpay.NewCallbackHandler(cbUrl)
+ cbSrv.Handle("POST "+cbUrl.Path, cbHlr)
+ go func() {
+ if err := http.ListenAndServe(cbUrl.Host, cbSrv); err != nil {
+ log.Fatal(err)
+ }
+ }()
+
+ // Request a transfer and register a callback for it
+ clnt := xmrpay.New(xmrPaySrvUrl)
+ resp, cb, err := clnt.ReceiveWithCallback(context.Background(), 123, "lorem ipsum", cbHlr)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer cb.Close()
+ fmt.Printf("POST /receive response: %s %d %q\n", resp.Address, resp.Amount, resp.Description)
+
+ // Wait for the transaction to finish
+ <-cb.Complete
+ fmt.Println("funds have been received")
+
+ // Output:
+ // POST /receive response: abcxyz 123 "lorem ipsum"
+ // funds have been received
+}
+
+type mockReceivePostHandler struct{}
+
+func (mockReceivePostHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/receive" {
+ panic(fmt.Sprintf("bad request: %s %s", r.Method, r.URL))
+ }
+
+ var req model.ReceivePostRequest
+ dec := json.NewDecoder(r.Body)
+ if err := dec.Decode(&req); err != nil {
+ panic(err)
+ }
+
+ // Send callbacks
+ go func() {
+ var cb model.CallbackResponse
+ cb.Amount.Expected = req.Amount
+ cb.Description = req.Description
+ cbData, err := json.Marshal(cb)
+ if err != nil {
+ panic(err)
+ }
+ period := time.Second
+ timer := time.NewTimer(period)
+ for i := 0; i < 3; i++ {
+ <-timer.C
+ timer.Reset(period)
+ _, err := http.Post(req.CallbackUrl, "application/json", bytes.NewBuffer(cbData))
+ if err != nil {
+ panic(err)
+ }
+ }
+
+ // Transaction is finally complete
+ cb.Complete = true
+ cbData, err = json.Marshal(cb)
+ if err != nil {
+ panic(err)
+ }
+ _, err = http.Post(req.CallbackUrl, "application/json", bytes.NewBuffer(cbData))
+ if err != nil {
+ panic(err)
+ }
+ }()
+
+ resp := model.ReceivePostResponse{
+ Address: "abcxyz",
+ Amount: req.Amount,
+ Description: req.Description,
+ }
+ enc := json.NewEncoder(w)
+ if err := enc.Encode(resp); err != nil {
+ panic(err)
+ }
+}
diff --git a/callback_test.go b/callback_test.go
new file mode 100644
index 0000000..c9de48a
--- /dev/null
+++ b/callback_test.go
@@ -0,0 +1,137 @@
+package xmrpayclnt
+
+import (
+ "net/url"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "gitlab.com/moneropay/moneropay/v2/pkg/model"
+)
+
+func newTestCallback(t *testing.T) Callback {
+ id := newCallbackId()
+ base, err := url.Parse("http://localhost")
+ require.NoError(t, err)
+ url := callbackUrl(base, id)
+ done := make(chan callbackId)
+ go func() {
+ // signal to callback that it can close
+ id := <-done
+ done <- id
+ close(done)
+ }()
+ return newCallback(id, url, done)
+}
+
+func TestCallbackRx(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ cb := newTestCallback(t)
+ defer cb.Close()
+ info := model.CallbackResponse{
+ Description: "lorem ipsum",
+ CreatedAt: time.Now(),
+ }
+ cb.in <- info
+ synctest.Wait()
+ require.Equal(t, info, <-cb.C)
+ })
+}
+
+// Callback blocks until it receives another callback POST request.
+func TestCallbackBlockUntilRx(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ cb := newTestCallback(t)
+ defer cb.Close()
+ info := model.CallbackResponse{
+ Description: "lorem ipsum",
+ CreatedAt: time.Now(),
+ }
+
+ for i := 0; i < 5; i++ {
+ requireBlockingf(t, cb.C, "callback not blocking")
+ cb.in <- info
+ synctest.Wait()
+ require.Equal(t, info, <-cb.C)
+ }
+ })
+}
+
+// C and Complete channels closed when transaction completes.
+func TestCallbackComplete(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ cb := newTestCallback(t)
+ defer cb.Close()
+
+ info := model.CallbackResponse{
+ Description: "lorem ipsum",
+ CreatedAt: time.Now(),
+ }
+ cb.in <- info
+ require.Equal(t, info, <-cb.C)
+ requireOpenf(t, cb.Complete, "complete channel closed before transaction is complete")
+
+ info.Complete = true
+ cb.in <- info
+ requireClosedf(t, cb.C, "channel not closed once transaction is finished")
+ requireClosedf(t, cb.Complete, "complete channel not closed once transaction finished")
+ })
+}
+
+// Channels closed when callback closed
+func TestCallbackClose(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ cb := newTestCallback(t)
+ requireOpen(t, cb.C)
+ requireOpen(t, cb.Complete)
+ cb.Close()
+ requireClosed(t, cb.C)
+ requireClosed(t, cb.Complete)
+ })
+}
+
+func requireBlockingf[T any](t *testing.T, c <-chan T, msg string, args ...any) {
+ t.Helper()
+ synctest.Wait()
+ select {
+ case <-c:
+ t.Fatalf(msg, args...)
+ default:
+ }
+}
+
+func requireOpen[T any](t *testing.T, c <-chan T) {
+ t.Helper()
+ requireOpenf(t, c, "channel not open")
+}
+
+func requireOpenf[T any](t *testing.T, c <-chan T, msg string, args ...any) {
+ t.Helper()
+ synctest.Wait()
+ select {
+ case _, ok := <-c:
+ if !ok {
+ t.Fatalf(msg, args...)
+ }
+ default:
+ }
+}
+
+func requireClosed[T any](t *testing.T, c <-chan T) {
+ t.Helper()
+ requireClosedf(t, c, "channel not closed")
+}
+
+func requireClosedf[T any](t *testing.T, c <-chan T, msg string, args ...any) {
+ t.Helper()
+ synctest.Wait()
+ select {
+ case _, ok := <-c:
+ if ok {
+ t.Fatalf(msg, args...)
+ }
+ default:
+ t.Fatalf(msg, args...)
+ }
+}
diff --git a/err.go b/err.go
new file mode 100644
index 0000000..cbd2336
--- /dev/null
+++ b/err.go
@@ -0,0 +1,17 @@
+package xmrpayclnt
+
+import (
+ "fmt"
+ "net/http"
+)
+
+type errResp struct {
+ resp *http.Response
+ wantStatus int
+}
+
+func (e errResp) Error() string {
+ req := e.resp.Request
+ return fmt.Sprintf("%s %s: %s (expected %d)",
+ req.Method, req.URL, e.resp.Status, e.wantStatus)
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..a8f0364
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,13 @@
+module git.samanthony.xyz/xmrpayclnt
+
+go 1.25.9
+
+require (
+ git.samanthony.xyz/jttp v0.0.0-20260613153820-21958dacc6b3 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
+ gitlab.com/moneropay/go-monero v1.1.2 // indirect
+ gitlab.com/moneropay/moneropay/v2 v2.8.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..db71eee
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,15 @@
+git.samanthony.xyz/jttp v0.0.0-20260613153820-21958dacc6b3 h1:kuddvsfScqhWoMyywj6Q1GFZWmn61K2Ha9+M4KwORko=
+git.samanthony.xyz/jttp v0.0.0-20260613153820-21958dacc6b3/go.mod h1:WzDYcU8XtdX/vy9CmZAZL1pLwPTxSDdlrYm5Bwsz5z8=
+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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+gitlab.com/moneropay/go-monero v1.1.2 h1:B9rl3rhsy8eAz4xEhIA4DZ2BgQrbdlsxejP7pe2lS38=
+gitlab.com/moneropay/go-monero v1.1.2/go.mod h1:k7fElrhjex1ktCy45ebcgz66oGBeOtciBZA405s3Oz0=
+gitlab.com/moneropay/moneropay/v2 v2.8.1 h1:o5pNYdBP4D9OWs8OULl0Bno4uD8oRRn6Eoxc0jBYdqI=
+gitlab.com/moneropay/moneropay/v2 v2.8.1/go.mod h1:HvFpfNEoT+ErOa+PRdq00RJGOUAbyBoCM9scgw4jags=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+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/xmrpayclnt.go b/xmrpayclnt.go
new file mode 100644
index 0000000..5d865bf
--- /dev/null
+++ b/xmrpayclnt.go
@@ -0,0 +1,95 @@
+// Package xmrpayclnt is a client library for the MoneroPay v2 HTTP API.
+//
+// All amounts are in piconero.
+package xmrpayclnt
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+
+ "git.samanthony.xyz/jttp"
+
+ "gitlab.com/moneropay/moneropay/v2/pkg/model"
+)
+
+const (
+ balancePath = "/balance"
+ healthPath = "/health"
+ receivePath = "/receive"
+)
+
+var lg = log.New(os.Stderr, "xmrpayclnt: ", log.LstdFlags)
+
+type Client struct {
+ c *jttp.Client
+ endpoint *url.URL
+}
+
+// New creates a client that will connect to the moneropay server at
+// endpoint.
+func New(endpoint *url.URL) *Client {
+ return &Client{jttp.NewClient(&http.Client{}), endpoint}
+}
+
+// Balance returns the entire wallet balance.
+func (c *Client) Balance(ctx context.Context) (model.BalanceResponse, error) {
+ url := c.endpoint.JoinPath(balancePath)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
+ if err != nil {
+ return model.BalanceResponse{}, err
+ }
+ var bal model.BalanceResponse
+ err = c.c.Do(req, http.StatusOK, &bal)
+ return bal, err
+}
+
+// Health returns true if all required services are up.
+func (c *Client) Health(ctx context.Context) (bool, error) {
+ url := c.endpoint.JoinPath(healthPath)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
+ if err != nil {
+ return false, err
+ }
+ var health model.HealthResponse
+ err = c.c.Do(req, http.StatusOK, &health)
+ return err == nil, err
+}
+
+// Receive creates a subaddress for incoming transfers.
+func (c *Client) Receive(ctx context.Context, amount uint64, description string) (model.ReceivePostResponse, error) {
+ return c.postReceive(ctx, amount, description, "")
+}
+
+func (c *Client) ReceiveWithCallback(ctx context.Context, amount uint64, description string, ch *CallbackHandler) (model.ReceivePostResponse, Callback, error) {
+ cb := ch.listen()
+ if resp, err := c.postReceive(ctx, amount, description, cb.url.String()); err == nil {
+ return resp, cb, nil
+ } else {
+ cb.Close()
+ return resp, Callback{}, err
+ }
+}
+
+func (c *Client) postReceive(ctx context.Context, amount uint64, description string, callbackUrl string) (model.ReceivePostResponse, error) {
+ var resp model.ReceivePostResponse
+
+ body := new(bytes.Buffer)
+ enc := json.NewEncoder(body)
+ err := enc.Encode(model.ReceivePostRequest{amount, callbackUrl, description})
+ if err != nil {
+ return resp, err
+ }
+
+ url := c.endpoint.JoinPath(receivePath)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), body)
+ if err != nil {
+ return resp, err
+ }
+ err = c.c.Do(req, http.StatusOK, &resp)
+ return resp, err
+}