Skip to content

Commit

Permalink
Merge pull request #164 from getAlby/task-list-invoices
Browse files Browse the repository at this point in the history
feat: add list transactions to nwc
  • Loading branch information
rolznz authored Dec 18, 2023
2 parents dffcf55 + 92fc7da commit 01087c3
Show file tree
Hide file tree
Showing 13 changed files with 620 additions and 87 deletions.
30 changes: 16 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light

NIP-47 info event

`expires` tag in requests

### LND

`get_info`
Expand All @@ -133,16 +135,15 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light

`pay_keysend`

⚠️ `make_invoice`
- ⚠️ invoice in response missing (TODO)
`make_invoice`

⚠️ `lookup_invoice`
- ⚠️ invoice in response missing (TODO)
- ⚠️ response does not match spec, missing fields
`lookup_invoice`

`list_transactions`
`list_transactions`
- ⚠️ from and until in request not supported
- ⚠️ failed payments will not be returned

`multi_pay_invoice (TBC)`
`multi_pay_invoice`

`multi_pay_keysend (TBC)`

Expand All @@ -162,16 +163,17 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light
`pay_keysend`
- ⚠️ preimage in request not supported

⚠️ `make_invoice`
`make_invoice`
- ⚠️ expiry in request not supported
- ⚠️ invoice in response missing (TODO)

⚠️ `lookup_invoice`
- ⚠️ invoice in response missing (TODO)
- ⚠️ response does not match spec, missing fields (TODO)
`lookup_invoice`
- ⚠️ fees_paid in response not supported

`list_transactions`
`list_transactions`
- ⚠️ offset and unpaid in request not supported
- ⚠️ fees_paid in response not supported
- ⚠️ unsettled and failed transactions will not be returned

`multi_pay_invoice (TBC)`
`multi_pay_invoice`

`multi_pay_keysend (TBC)`
173 changes: 154 additions & 19 deletions alby.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"

"github.com/labstack/echo-contrib/session"
Expand Down Expand Up @@ -78,7 +79,7 @@ func (svc *AlbyOAuthService) FetchUserToken(ctx context.Context, app App) (token
return tok, nil
}

func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) {
func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (transaction *Nip47Transaction, err error) {
// TODO: move to a shared function
app := App{}
err = svc.db.Preload("User").First(&app, &App{
Expand All @@ -92,7 +93,7 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin
"descriptionHash": descriptionHash,
"expiry": expiry,
}).Errorf("App not found: %v", err)
return "", "", err
return nil, err
}

// amount provided in msat, but Alby API currently only supports sats. Will get truncated to a whole sat value
Expand All @@ -106,7 +107,7 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin
"descriptionHash": descriptionHash,
"expiry": expiry,
}).Errorf("amount must be 1000 msat or greater")
return "", "", errors.New("amount must be 1000 msat or greater")
return nil, errors.New("amount must be 1000 msat or greater")
}

svc.Logger.WithFields(logrus.Fields{
Expand All @@ -120,7 +121,7 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin
}).Info("Processing make invoice request")
tok, err := svc.FetchUserToken(ctx, app)
if err != nil {
return "", "", err
return nil, err
}
client := svc.oauthConf.Client(ctx, tok)

Expand All @@ -137,7 +138,7 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin
req, err := http.NewRequest("POST", fmt.Sprintf("%s/invoices", svc.cfg.AlbyAPIURL), body)
if err != nil {
svc.Logger.WithError(err).Error("Error creating request /invoices")
return "", "", err
return nil, err
}

// TODO: move to creation of HTTP client
Expand All @@ -155,14 +156,14 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin
"appId": app.ID,
"userId": app.User.ID,
}).Errorf("Failed to make invoice: %v", err)
return "", "", err
return nil, err
}

if resp.StatusCode < 300 {
responsePayload := &MakeInvoiceResponse{}
responsePayload := &AlbyInvoice{}
err = json.NewDecoder(resp.Body).Decode(responsePayload)
if err != nil {
return "", "", err
return nil, err
}
svc.Logger.WithFields(logrus.Fields{
"senderPubkey": senderPubkey,
Expand All @@ -175,7 +176,9 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin
"paymentRequest": responsePayload.PaymentRequest,
"paymentHash": responsePayload.PaymentHash,
}).Info("Make invoice successful")
return responsePayload.PaymentRequest, responsePayload.PaymentHash, nil

transaction := albyInvoiceToTransaction(responsePayload)
return transaction, nil
}

errorPayload := &ErrorResponse{}
Expand All @@ -190,10 +193,10 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin
"userId": app.User.ID,
"APIHttpStatus": resp.StatusCode,
}).Errorf("Make invoice failed %s", string(errorPayload.Message))
return "", "", errors.New(errorPayload.Message)
return nil, errors.New(errorPayload.Message)
}

func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (invoice string, paid bool, err error) {
func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (transaction *Nip47Transaction, err error) {
// TODO: move to a shared function
app := App{}
err = svc.db.Preload("User").First(&app, &App{
Expand All @@ -204,7 +207,7 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str
"senderPubkey": senderPubkey,
"paymentHash": paymentHash,
}).Errorf("App not found: %v", err)
return "", false, err
return nil, err
}

svc.Logger.WithFields(logrus.Fields{
Expand All @@ -215,7 +218,7 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str
}).Info("Processing lookup invoice request")
tok, err := svc.FetchUserToken(ctx, app)
if err != nil {
return "", false, err
return nil, err
}
client := svc.oauthConf.Client(ctx, tok)

Expand All @@ -225,7 +228,7 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str
req, err := http.NewRequest("GET", fmt.Sprintf("%s/invoices/%s", svc.cfg.AlbyAPIURL, paymentHash), body)
if err != nil {
svc.Logger.WithError(err).Errorf("Error creating request /invoices/%s", paymentHash)
return "", false, err
return nil, err
}

req.Header.Set("User-Agent", "NWC")
Expand All @@ -239,14 +242,14 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str
"appId": app.ID,
"userId": app.User.ID,
}).Errorf("Failed to lookup invoice: %v", err)
return "", false, err
return nil, err
}

if resp.StatusCode < 300 {
responsePayload := &LookupInvoiceResponse{}
responsePayload := &AlbyInvoice{}
err = json.NewDecoder(resp.Body).Decode(responsePayload)
if err != nil {
return "", false, err
return nil, err
}
svc.Logger.WithFields(logrus.Fields{
"senderPubkey": senderPubkey,
Expand All @@ -256,7 +259,9 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str
"paymentRequest": responsePayload.PaymentRequest,
"settled": responsePayload.Settled,
}).Info("Lookup invoice successful")
return responsePayload.PaymentRequest, responsePayload.Settled, nil

transaction = albyInvoiceToTransaction(responsePayload)
return transaction, nil
}

errorPayload := &ErrorResponse{}
Expand All @@ -268,7 +273,7 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str
"userId": app.User.ID,
"APIHttpStatus": resp.StatusCode,
}).Errorf("Lookup invoice failed %s", string(errorPayload.Message))
return "", false, errors.New(errorPayload.Message)
return nil, errors.New(errorPayload.Message)
}

func (svc *AlbyOAuthService) GetInfo(ctx context.Context, senderPubkey string) (info *NodeInfo, err error) {
Expand Down Expand Up @@ -358,6 +363,110 @@ func (svc *AlbyOAuthService) GetBalance(ctx context.Context, senderPubkey string
return 0, errors.New(errorPayload.Message)
}

func (svc *AlbyOAuthService) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (transactions []Nip47Transaction, err error) {
app := App{}
err = svc.db.Preload("User").First(&app, &App{
NostrPubkey: senderPubkey,
}).Error
if err != nil {
svc.Logger.WithFields(logrus.Fields{
"senderPubkey": senderPubkey,
}).Errorf("App not found: %v", err)
return nil, err
}
tok, err := svc.FetchUserToken(ctx, app)
if err != nil {
return nil, err
}
client := svc.oauthConf.Client(ctx, tok)

urlParams := url.Values{}
//urlParams.Add("page", "1")

// TODO: clarify gt/lt vs from to in NWC spec
if from != 0 {
urlParams.Add("q[created_at_gt]", strconv.FormatUint(from, 10))
}
if until != 0 {
urlParams.Add("q[created_at_lt]", strconv.FormatUint(until, 10))
}
if limit != 0 {
urlParams.Add("items", strconv.FormatUint(limit, 10))
}
// TODO: Add Offset and Unpaid

endpoint := "/invoices"

switch invoiceType {
case "incoming":
endpoint += "/incoming"
case "outgoing":
endpoint += "/outgoing"
}

requestUrl := fmt.Sprintf("%s%s?%s", svc.cfg.AlbyAPIURL, endpoint, urlParams.Encode())

req, err := http.NewRequest("GET", requestUrl, nil)
if err != nil {
svc.Logger.WithError(err).Error("Error creating request /invoices")
return nil, err
}

req.Header.Set("User-Agent", "NWC")

resp, err := client.Do(req)
if err != nil {
svc.Logger.WithFields(logrus.Fields{
"senderPubkey": senderPubkey,
"appId": app.ID,
"userId": app.User.ID,
"requestUrl": requestUrl,
}).Errorf("Failed to fetch invoices: %v", err)
return nil, err
}

var invoices []AlbyInvoice

if resp.StatusCode < 300 {
err = json.NewDecoder(resp.Body).Decode(&invoices)
if err != nil {
svc.Logger.WithFields(logrus.Fields{
"senderPubkey": senderPubkey,
"appId": app.ID,
"userId": app.User.ID,
"requestUrl": requestUrl,
}).Errorf("Failed to decode invoices: %v", err)
return nil, err
}

transactions = []Nip47Transaction{}
for _, invoice := range invoices {
transaction := albyInvoiceToTransaction(&invoice)

transactions = append(transactions, *transaction)
}

svc.Logger.WithFields(logrus.Fields{
"senderPubkey": senderPubkey,
"appId": app.ID,
"userId": app.User.ID,
"requestUrl": requestUrl,
}).Info("List transactions successful")
return transactions, nil
}

errorPayload := &ErrorResponse{}
err = json.NewDecoder(resp.Body).Decode(errorPayload)
svc.Logger.WithFields(logrus.Fields{
"senderPubkey": senderPubkey,
"appId": app.ID,
"userId": app.User.ID,
"APIHttpStatus": resp.StatusCode,
"requestUrl": requestUrl,
}).Errorf("List transactions failed %s", string(errorPayload.Message))
return nil, errors.New(errorPayload.Message)
}

func (svc *AlbyOAuthService) SendPaymentSync(ctx context.Context, senderPubkey, payReq string) (preimage string, err error) {
app := App{}
err = svc.db.Preload("User").First(&app, &App{
Expand Down Expand Up @@ -589,3 +698,29 @@ func (svc *AlbyOAuthService) CallbackHandler(c echo.Context) error {
sess.Save(c.Request(), c.Response())
return c.Redirect(302, "/")
}

func albyInvoiceToTransaction(invoice *AlbyInvoice) *Nip47Transaction {
description := invoice.Comment
if description == "" {
description = invoice.Memo
}
var preimage string
if invoice.SettledAt != nil {
preimage = invoice.Preimage
}

return &Nip47Transaction{
Type: invoice.Type,
Invoice: invoice.PaymentRequest,
Description: description,
DescriptionHash: invoice.DescriptionHash,
Preimage: preimage,
PaymentHash: invoice.PaymentHash,
Amount: invoice.Amount * 1000,
FeesPaid: 0, // TODO: support fees
CreatedAt: invoice.CreatedAt,
ExpiresAt: invoice.ExpiresAt,
SettledAt: invoice.SettledAt,
Metadata: invoice.Metadata,
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.20

require (
github.com/davrux/echo-logrus/v4 v4.0.3
github.com/go-gormigrate/gormigrate/v2 v2.1.1
github.com/gorilla/sessions v1.2.1
github.com/labstack/echo-contrib v0.14.1
github.com/labstack/echo/v4 v4.10.2
Expand Down Expand Up @@ -55,7 +56,6 @@ require (
github.com/fergusstrange/embedded-postgres v1.19.0 // indirect
github.com/glebarez/go-sqlite v1.20.3 // indirect
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-gormigrate/gormigrate/v2 v2.1.1 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-macaroon-bakery/macaroonpb v1.0.0 // indirect
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1345,8 +1345,6 @@ gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0=
gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8=
gorm.io/driver/sqlserver v1.0.4 h1:V15fszi0XAo7fbx3/cF50ngshDSN4QT0MXpWTylyPTY=
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU=
gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.4 h1:iyNd8fNAe8W9dvtlgeRI5zSVZPsq3OpcTu37cYcpCmw=
gorm.io/gorm v1.25.4/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
Expand Down
Loading

0 comments on commit 01087c3

Please sign in to comment.