diff options
| author | Sam Anthony <sam@samanthony.xyz> | 2026-06-30 16:03:45 -0230 |
|---|---|---|
| committer | Sam Anthony <sam@samanthony.xyz> | 2026-06-30 16:03:45 -0230 |
| commit | 7d0f3d56121feccfe84600a39c362ba8bfcaa042 (patch) | |
| tree | 0e412db49a133af1020ae13d6a0f3930fa04ee06 | |
| download | xmrpayclnt-7d0f3d56121feccfe84600a39c362ba8bfcaa042.zip | |
init
| -rw-r--r-- | README | 6 | ||||
| -rw-r--r-- | callback.go | 164 | ||||
| -rw-r--r-- | callback_handler_test.go | 116 | ||||
| -rw-r--r-- | callback_test.go | 137 | ||||
| -rw-r--r-- | err.go | 17 | ||||
| -rw-r--r-- | go.mod | 13 | ||||
| -rw-r--r-- | go.sum | 15 | ||||
| -rw-r--r-- | xmrpayclnt.go | 95 |
8 files changed, 563 insertions, 0 deletions
@@ -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...) + } +} @@ -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) +} @@ -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 +) @@ -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 +} |