diff --git a/README.md b/README.md index 628ae67b..b4467f17 100644 --- a/README.md +++ b/README.md @@ -138,9 +138,10 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light - ⚠️ invoice in response missing (TODO) - ⚠️ response does not match spec, missing fields -❌ `pay_keysend` +✅ `list_transactions` +- ⚠️ from and until in request not supported -❌ `list_transactions` +❌ `pay_keysend` ❌ `multi_pay_invoice (TBC)` @@ -167,9 +168,10 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light - ⚠️ invoice in response missing (TODO) - ⚠️ response does not match spec, missing fields (TODO) -❌ `pay_keysend` +✅ `list_transactions` +- ⚠️ offset and unpaid in request not supported -❌ `list_transactions` +❌ `pay_keysend` ❌ `multi_pay_invoice (TBC)` diff --git a/lnd.go b/lnd.go index aba68b2a..c0218201 100644 --- a/lnd.go +++ b/lnd.go @@ -63,13 +63,26 @@ func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string if err != nil { return nil, err } - resp, err := svc.client.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{NumMaxInvoices: maxInvoices, IndexOffset: indexOffset}) - if err != nil { - return nil, err + // Fetch Incoming Payments + var incomingInvoices []*lnrpc.Invoice + if invoiceType == "" || invoiceType == "incoming" { + incomingResp, err := svc.client.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{NumMaxInvoices: maxInvoices, IndexOffset: indexOffset}) + if err != nil { + return nil, err + } + incomingInvoices = incomingResp.Invoices + + if unpaid { + incomingUnpaidResp, err := svc.client.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{NumMaxInvoices: maxInvoices, IndexOffset: indexOffset, PendingOnly: true}) + if err != nil { + return nil, err + } + incomingInvoices = append(incomingInvoices, incomingUnpaidResp.Invoices...) + } } - - for _, inv := range resp.Invoices { + for _, inv := range incomingInvoices { invoice := Invoice{ + Type: "incoming", Invoice: inv.PaymentRequest, Description: inv.Memo, DescriptionHash: hex.EncodeToString(inv.DescriptionHash), @@ -77,7 +90,31 @@ func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string PaymentHash: hex.EncodeToString(inv.RHash), Amount: inv.ValueMsat, FeesPaid: inv.AmtPaidMsat, + CreatedAt: time.Unix(inv.CreationDate, 0), SettledAt: time.Unix(inv.SettleDate, 0), + ExpiresAt: time.Unix(inv.CreationDate+inv.Expiry, 0), + } + invoices = append(invoices, invoice) + } + // Fetch Outgoing Invoices + var outgoingInvoices []*lnrpc.Payment + if invoiceType == "" || invoiceType == "incoming" { + // Not just pending but failed payments will also be included because of IncludeIncomplete + outgoingResp, err := svc.client.ListPayments(ctx, &lnrpc.ListPaymentsRequest{MaxPayments: maxInvoices, IndexOffset: indexOffset, IncludeIncomplete: unpaid}) + if err != nil { + return nil, err + } + outgoingInvoices = outgoingResp.Payments + } + for _, inv := range outgoingInvoices { + invoice := Invoice{ + Type: "outgoing", + Invoice: inv.PaymentRequest, + Preimage: inv.PaymentPreimage, + PaymentHash: inv.PaymentHash, + Amount: inv.ValueMsat, + FeesPaid: inv.FeeMsat, + CreatedAt: time.Unix(0, inv.CreationTimeNs), } invoices = append(invoices, invoice) } diff --git a/lnd/lnd.go b/lnd/lnd.go index e2578df4..dd429059 100644 --- a/lnd/lnd.go +++ b/lnd/lnd.go @@ -124,6 +124,10 @@ func (wrapper *LNDWrapper) ListInvoices(ctx context.Context, req *lnrpc.ListInvo return wrapper.client.ListInvoices(ctx, req, options...) } +func (wrapper *LNDWrapper) ListPayments(ctx context.Context, req *lnrpc.ListPaymentsRequest, options ...grpc.CallOption) (*lnrpc.ListPaymentsResponse, error) { + return wrapper.client.ListPayments(ctx, req, options...) +} + func (wrapper *LNDWrapper) LookupInvoice(ctx context.Context, req *lnrpc.PaymentHash, options ...grpc.CallOption) (*lnrpc.Invoice, error) { return wrapper.client.LookupInvoice(ctx, req, options...) } diff --git a/models.go b/models.go index 52127aec..670837e0 100644 --- a/models.go +++ b/models.go @@ -133,6 +133,8 @@ type Invoice struct { PaymentHash string `json:"payment_hash"` Amount int64 `json:"amount"` FeesPaid int64 `json:"fees_paid"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` SettledAt time.Time `json:"settled_at"` Metadata map[string]interface{} `json:"metadata,omitempty"` } diff --git a/service_test.go b/service_test.go index 70c7f333..79550976 100644 --- a/service_test.go +++ b/service_test.go @@ -47,6 +47,18 @@ const nip47LookupInvoiceJson = ` } } ` +const nip47ListTransactionsJson = ` +{ + "method": "list_transactions", + "params": { + "from": 1693876973, + "until": 1694876973, + "limit": 10, + "offset": 0, + "type": "incoming" + } +} +` const nip47PayJson = ` { "method": "pay_invoice", @@ -85,6 +97,7 @@ var mockNodeInfo = NodeInfo{ var mockInvoices = []Invoice{ { + Type: "incoming", Invoice: mockInvoice, Description: "mock invoice 1", DescriptionHash: "hash1", @@ -92,13 +105,14 @@ var mockInvoices = []Invoice{ PaymentHash: "payment_hash_1", Amount: 1000, FeesPaid: 50, - SettledAt: time.Now(), + SettledAt: time.Unix(1693876963, 0), Metadata: map[string]interface{}{ "key1": "value1", "key2": 42, }, }, { + Type: "incoming", Invoice: mockInvoice, Description: "mock invoice 2", DescriptionHash: "hash2", @@ -106,7 +120,7 @@ var mockInvoices = []Invoice{ PaymentHash: "payment_hash_2", Amount: 2000, FeesPaid: 75, - SettledAt: time.Now(), + SettledAt: time.Unix(1693876965, 0), }, } @@ -432,6 +446,54 @@ func TestHandleEvent(t *testing.T) { assert.Equal(t, mockInvoice, received.Result.(*Nip47LookupInvoiceResponse).Invoice) assert.Equal(t, false, received.Result.(*Nip47LookupInvoiceResponse).Paid) + // list_transactions: without permission + newPayload, err = nip04.Encrypt(nip47ListTransactionsJson, ss) + assert.NoError(t, err) + res, err = svc.HandleEvent(ctx, &nostr.Event{ + ID: "test_list_transactions_event_1", + Kind: NIP_47_REQUEST_KIND, + PubKey: senderPubkey, + Content: newPayload, + }) + assert.NoError(t, err) + assert.NotNil(t, res) + decrypted, err = nip04.Decrypt(res.Content, ss) + assert.NoError(t, err) + received = &Nip47Response{} + err = json.Unmarshal([]byte(decrypted), received) + assert.NoError(t, err) + assert.Equal(t, NIP_47_ERROR_RESTRICTED, received.Error.Code) + assert.NotNil(t, res) + + // list_transactions: with permission + err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("request_method", NIP_47_LIST_TRANSACTIONS_METHOD).Error + assert.NoError(t, err) + res, err = svc.HandleEvent(ctx, &nostr.Event{ + ID: "test_list_transactions_event_2", + Kind: NIP_47_REQUEST_KIND, + PubKey: senderPubkey, + Content: newPayload, + }) + assert.NoError(t, err) + assert.NotNil(t, res) + decrypted, err = nip04.Decrypt(res.Content, ss) + assert.NoError(t, err) + received = &Nip47Response{ + Result: &Nip47ListTransactionsResponse{}, + } + err = json.Unmarshal([]byte(decrypted), received) + assert.NoError(t, err) + assert.Equal(t, 2, len(received.Result.(*Nip47ListTransactionsResponse).Transactions)) + transaction := received.Result.(*Nip47ListTransactionsResponse).Transactions[0] + assert.Equal(t, mockInvoices[0].Type, transaction.Type) + assert.Equal(t, mockInvoices[0].Invoice, transaction.Invoice) + assert.Equal(t, mockInvoices[0].Description, transaction.Description) + assert.Equal(t, mockInvoices[0].DescriptionHash, transaction.DescriptionHash) + assert.Equal(t, mockInvoices[0].Preimage, transaction.Preimage) + assert.Equal(t, mockInvoices[0].PaymentHash, transaction.PaymentHash) + assert.Equal(t, mockInvoices[0].Amount, transaction.Amount) + assert.Equal(t, mockInvoices[0].FeesPaid, transaction.FeesPaid) + assert.Equal(t, mockInvoices[0].SettledAt, transaction.SettledAt) // get info: without permission newPayload, err = nip04.Encrypt(nip47GetInfoJson, ss) assert.NoError(t, err)