diff --git a/.env.example b/.env.example index db1e3049..e0e7c094 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,18 @@ DATABASE_URI=file:nwc.db NOSTR_PRIVKEY= -LN_BACKEND_TYPE=ALBY -ALBY_CLIENT_SECRET= -ALBY_CLIENT_ID= -OAUTH_REDIRECT_URL=http://localhost:8080/alby/callback COOKIE_SECRET=secretsecret RELAY=wss://relay.getalby.com/v1 PUBLIC_RELAY= PORT=8080 + +# Polar LND Client +#LN_BACKEND_TYPE=LND +#LND_CERT_FILE=/home/YOUR_USERNAME/.polar/networks/1/volumes/lnd/alice/tls.cert +#LND_ADDRESS=127.0.0.1:10001 +#LND_MACAROON_FILE=/home/YOUR_USERNAME/.polar/networks/1/volumes/lnd/alice/data/chain/bitcoin/regtest/admin.macaroon + +# Alby Wallet API Client +#LN_BACKEND_TYPE=ALBY +#ALBY_CLIENT_SECRET= +#ALBY_CLIENT_ID= +#OAUTH_REDIRECT_URL=http://localhost:8080/alby/callback \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..ca1751b9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.defaultFormatter": "golang.go" +} \ No newline at end of file diff --git a/README.md b/README.md index 38458b27..628ae67b 100644 --- a/README.md +++ b/README.md @@ -117,3 +117,60 @@ Want to support the work on Alby? Support the Alby team ⚡️hello@getalby.com You can also contribute to our [bounty program](https://github.com/getAlby/lightning-browser-extension/wiki/Bounties): ⚡️bounties@getalby.com + + +## NIP-47 Supported Methods + +✅ NIP-47 info event + +### LND + +✅ `get_info` + +✅ `get_balance` + +✅ `pay_invoice` + +⚠️ `make_invoice` +- ⚠️ invoice in response missing (TODO) + +⚠️ `lookup_invoice` +- ⚠️ invoice in response missing (TODO) +- ⚠️ response does not match spec, missing fields + +❌ `pay_keysend` + +❌ `list_transactions` + +❌ `multi_pay_invoice (TBC)` + +❌ `multi_pay_keysend (TBC)` + +### Alby OAuth API + +✅ `get_info` +- ⚠️ block_hash not supported +- ⚠️ block_height not supported +- ⚠️ pubkey not supported +- ⚠️ color not supported +- ⚠️ network is always `mainnet` + +✅ `get_balance` + +✅ `pay_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) + +❌ `pay_keysend` + +❌ `list_transactions` + +❌ `multi_pay_invoice (TBC)` + +❌ `multi_pay_keysend (TBC)` diff --git a/alby.go b/alby.go index 255bd324..874748fe 100644 --- a/alby.go +++ b/alby.go @@ -139,6 +139,7 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin return "", "", err } + // TODO: move to creation of HTTP client req.Header.Set("User-Agent", "NWC") req.Header.Set("Content-Type", "application/json") @@ -269,6 +270,33 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str return "", false, errors.New(errorPayload.Message) } +func (svc *AlbyOAuthService) GetInfo(ctx context.Context, senderPubkey string) (info *NodeInfo, 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 + } + + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + "appId": app.ID, + "userId": app.User.ID, + }).Info("Info fetch successful") + return &NodeInfo{ + Alias: "getalby.com", + Color: "", + Pubkey: "", + Network: "mainnet", + BlockHeight: 0, + BlockHash: "", + }, err +} + func (svc *AlbyOAuthService) GetBalance(ctx context.Context, senderPubkey string) (balance int64, err error) { app := App{} err = svc.db.Preload("User").First(&app, &App{ diff --git a/handle_info_request.go b/handle_info_request.go new file mode 100644 index 00000000..799eed18 --- /dev/null +++ b/handle_info_request.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "fmt" + + "github.com/nbd-wtf/go-nostr" + "github.com/sirupsen/logrus" +) + +func (svc *Service) HandleGetInfoEvent(ctx context.Context, request *Nip47Request, event *nostr.Event, app App, ss []byte) (result *nostr.Event, err error) { + + nostrEvent := NostrEvent{App: app, NostrId: event.ID, Content: event.Content, State: "received"} + err = svc.db.Create(&nostrEvent).Error + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + }).Errorf("Failed to save nostr event: %v", err) + return nil, err + } + + hasPermission, code, message := svc.hasPermission(&app, event, request.Method, nil) + + if !hasPermission { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + }).Errorf("App does not have permission: %s %s", code, message) + + return svc.createResponse(event, Nip47Response{ + ResultType: request.Method, + Error: &Nip47Error{ + Code: code, + Message: message, + }}, ss) + } + + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + }).Info("Fetching node info") + + info, err := svc.lnClient.GetInfo(ctx, event.PubKey) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + }).Infof("Failed to fetch node info: %v", err) + nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_ERROR + svc.db.Save(&nostrEvent) + return svc.createResponse(event, Nip47Response{ + ResultType: request.Method, + Error: &Nip47Error{ + Code: NIP_47_ERROR_INTERNAL, + Message: fmt.Sprintf("Something went wrong while fetching node info: %s", err.Error()), + }, + }, ss) + } + + responsePayload := &Nip47GetInfoResponse{ + Alias: info.Alias, + Color: info.Color, + Pubkey: info.Pubkey, + Network: info.Network, + BlockHeight: info.BlockHeight, + BlockHash: info.BlockHash, + Methods: svc.GetMethods(&app), + } + + nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_EXECUTED + svc.db.Save(&nostrEvent) + return svc.createResponse(event, Nip47Response{ + ResultType: request.Method, + Result: responsePayload, + }, ss) +} diff --git a/lnd.go b/lnd.go index 199e34d5..f0855e32 100644 --- a/lnd.go +++ b/lnd.go @@ -18,6 +18,7 @@ import ( type LNClient interface { SendPaymentSync(ctx context.Context, senderPubkey string, payReq string) (preimage string, err error) GetBalance(ctx context.Context, senderPubkey string) (balance int64, err error) + GetInfo(ctx context.Context, senderPubkey string) (info *NodeInfo, err error) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (invoice string, paid bool, err error) } @@ -51,6 +52,21 @@ func (svc *LNDService) GetBalance(ctx context.Context, senderPubkey string) (bal return int64(resp.LocalBalance.Sat), nil } +func (svc *LNDService) GetInfo(ctx context.Context, senderPubkey string) (info *NodeInfo, err error) { + resp, err := svc.client.GetInfo(ctx, &lnrpc.GetInfoRequest{}) + if err != nil { + return nil, err + } + return &NodeInfo{ + Alias: resp.Alias, + Color: resp.Color, + Pubkey: resp.IdentityPubkey, + Network: resp.Chains[0].Network, + BlockHeight: resp.BlockHeight, + BlockHash: resp.BlockHash, + }, nil +} + func (svc *LNDService) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) { var descriptionHashBytes []byte diff --git a/models.go b/models.go index aa5d3352..c1cc07c2 100644 --- a/models.go +++ b/models.go @@ -13,6 +13,7 @@ const ( NIP_47_RESPONSE_KIND = 23195 NIP_47_PAY_INVOICE_METHOD = "pay_invoice" NIP_47_GET_BALANCE_METHOD = "get_balance" + NIP_47_GET_INFO_METHOD = "get_info" NIP_47_MAKE_INVOICE_METHOD = "make_invoice" NIP_47_LOOKUP_INVOICE_METHOD = "lookup_invoice" NIP_47_ERROR_INTERNAL = "INTERNAL" @@ -23,7 +24,7 @@ const ( NIP_47_ERROR_EXPIRED = "EXPIRED" NIP_47_ERROR_RESTRICTED = "RESTRICTED" NIP_47_OTHER = "OTHER" - NIP_47_CAPABILITIES = "pay_invoice,get_balance" + NIP_47_CAPABILITIES = "pay_invoice,get_balance,get_info,make_invoice,lookup_invoice" ) const ( @@ -36,6 +37,7 @@ const ( var nip47MethodDescriptions = map[string]string{ NIP_47_GET_BALANCE_METHOD: "Read your balance", + NIP_47_GET_INFO_METHOD: "Read your node info", NIP_47_PAY_INVOICE_METHOD: "Send payments", NIP_47_MAKE_INVOICE_METHOD: "Create invoices", NIP_47_LOOKUP_INVOICE_METHOD: "Lookup status of invoices", @@ -43,11 +45,13 @@ var nip47MethodDescriptions = map[string]string{ var nip47MethodIcons = map[string]string{ NIP_47_GET_BALANCE_METHOD: "wallet", + NIP_47_GET_INFO_METHOD: "wallet", NIP_47_PAY_INVOICE_METHOD: "lightning", NIP_47_MAKE_INVOICE_METHOD: "invoice", NIP_47_LOOKUP_INVOICE_METHOD: "search", } +// TODO: move to models/Alby type AlbyMe struct { Identifier string `json:"identifier"` NPub string `json:"nostr_pubkey"` @@ -121,6 +125,7 @@ type PayRequest struct { Invoice string `json:"invoice"` } +// TODO: move to models/Alby type BalanceResponse struct { Balance int64 `json:"balance"` Currency string `json:"currency"` @@ -154,6 +159,16 @@ type ErrorResponse struct { Message string `json:"message"` } +// TODO: move to models/LNClient +type NodeInfo struct { + Alias string + Color string + Pubkey string + Network string + BlockHeight uint32 + BlockHash string +} + type Identity struct { gorm.Model Privkey string @@ -187,6 +202,17 @@ type Nip47BalanceResponse struct { BudgetRenewal string `json:"budget_renewal"` } +// TODO: move to models/Nip47 +type Nip47GetInfoResponse struct { + Alias string `json:"alias"` + Color string `json:"color"` + Pubkey string `json:"pubkey"` + Network string `json:"network"` + BlockHeight uint32 `json:"block_height"` + BlockHash string `json:"block_hash"` + Methods []string `json:"methods"` +} + type Nip47MakeInvoiceParams struct { Amount int64 `json:"amount"` Description string `json:"description"` diff --git a/service.go b/service.go index f00a360a..61b23857 100644 --- a/service.go +++ b/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" "github.com/labstack/echo-contrib/session" @@ -26,6 +27,7 @@ type Service struct { var supportedMethods = map[string]bool{ NIP_47_PAY_INVOICE_METHOD: true, NIP_47_GET_BALANCE_METHOD: true, + NIP_47_GET_INFO_METHOD: true, NIP_47_MAKE_INVOICE_METHOD: true, NIP_47_LOOKUP_INVOICE_METHOD: true, } @@ -207,6 +209,8 @@ func (svc *Service) HandleEvent(ctx context.Context, event *nostr.Event) (result return svc.HandleMakeInvoiceEvent(ctx, nip47Request, event, app, ss) case NIP_47_LOOKUP_INVOICE_METHOD: return svc.HandleLookupInvoiceEvent(ctx, nip47Request, event, app, ss) + case NIP_47_GET_INFO_METHOD: + return svc.HandleGetInfoEvent(ctx, nip47Request, event, app, ss) default: return svc.createResponse(event, Nip47Response{ ResultType: nip47Request.Method, @@ -240,6 +244,22 @@ func (svc *Service) createResponse(initialEvent *nostr.Event, content interface{ return resp, nil } +func (svc *Service) GetMethods(app *App) []string { + appPermissions := []AppPermission{} + findPermissionsResult := svc.db.Find(&appPermissions, &AppPermission{ + AppId: app.ID, + }) + if findPermissionsResult.RowsAffected == 0 { + // No permissions created for this app. It can do anything + return strings.Split(NIP_47_CAPABILITIES, ",") + } + requestMethods := make([]string, 0, len(appPermissions)) + for _, appPermission := range appPermissions { + requestMethods = append(requestMethods, appPermission.RequestMethod) + } + return requestMethods +} + func (svc *Service) hasPermission(app *App, event *nostr.Event, requestMethod string, paymentRequest *decodepay.Bolt11) (result bool, code string, message string) { // find all permissions for the app appPermissions := []AppPermission{} diff --git a/service_test.go b/service_test.go index 40b819fd..877e40b5 100644 --- a/service_test.go +++ b/service_test.go @@ -22,6 +22,13 @@ const nip47GetBalanceJson = ` "method": "get_balance" } ` + +const nip47GetInfoJson = ` +{ + "method": "get_info" +} +` + const nip47MakeInvoiceJson = ` { "method": "make_invoice", @@ -67,7 +74,16 @@ const nip47PayJsonNoInvoice = ` const mockInvoice = "lnbc10n1pjdy9aepp5ftvu6fucndg5mp5ww4ghsduqrxgr4rtcwelrln4jzxhem5qw022qhp50kncf9zk35xg4lxewt4974ry6mudygsztsz8qn3ar8pn3mtpe50scqzzsxqyz5vqsp5zyzp3dyn98g7sjlgy4nvujq3rh9xxsagytcyx050mf3rtrx3sn4s9qyyssq7af24ljqf5uzgnz4ualxhvffryh3kpkvvj76ah9yrgdvu494lmfrdf36h5wuhshzemkvrtg2zu70uk0fd9fcmxgl3j9748dvvx9ey9gqpr4jjd" const mockPaymentHash = "4ad9cd27989b514d868e755178378019903a8d78767e3fceb211af9dd00e7a94" // for the above invoice +var mockNodeInfo = NodeInfo{ + Alias: "bob", + Color: "#3399FF", + Pubkey: "123pubkey", + Network: "testnet", + BlockHeight: 12, + BlockHash: "123blockhash", +} +// TODO: split up into individual tests func TestHandleEvent(t *testing.T) { ctx := context.TODO() svc, _ := createTestService(t) @@ -331,14 +347,6 @@ func TestHandleEvent(t *testing.T) { // make invoice: with permission err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("request_method", NIP_47_MAKE_INVOICE_METHOD).Error - assert.NoError(t, err) - appPermission = &AppPermission{ - AppId: app.ID, - App: app, - RequestMethod: NIP_47_MAKE_INVOICE_METHOD, - ExpiresAt: expiresAt, - } - err = svc.db.Create(appPermission).Error res, err = svc.HandleEvent(ctx, &nostr.Event{ ID: "test_event_13", Kind: NIP_47_REQUEST_KIND, @@ -379,15 +387,56 @@ func TestHandleEvent(t *testing.T) { // lookup invoice: with permission err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("request_method", NIP_47_LOOKUP_INVOICE_METHOD).Error assert.NoError(t, err) + res, err = svc.HandleEvent(ctx, &nostr.Event{ + ID: "test_event_15", + 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: &Nip47LookupInvoiceResponse{}, + } + err = json.Unmarshal([]byte(decrypted), received) + assert.NoError(t, err) + assert.Equal(t, mockInvoice, received.Result.(*Nip47LookupInvoiceResponse).Invoice) + assert.Equal(t, false, received.Result.(*Nip47LookupInvoiceResponse).Paid) + + // get info: without permission + newPayload, err = nip04.Encrypt(nip47GetInfoJson, ss) + assert.NoError(t, err) + res, err = svc.HandleEvent(ctx, &nostr.Event{ + ID: "test_event_16", + 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) + + // delete all permissions + svc.db.Exec("delete from app_permissions") + + // lookup invoice: with permission appPermission = &AppPermission{ AppId: app.ID, App: app, - RequestMethod: NIP_47_LOOKUP_INVOICE_METHOD, + RequestMethod: NIP_47_GET_INFO_METHOD, ExpiresAt: expiresAt, } err = svc.db.Create(appPermission).Error res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_15", + ID: "test_event_17", Kind: NIP_47_REQUEST_KIND, PubKey: senderPubkey, Content: newPayload, @@ -397,12 +446,17 @@ func TestHandleEvent(t *testing.T) { decrypted, err = nip04.Decrypt(res.Content, ss) assert.NoError(t, err) received = &Nip47Response{ - Result: &Nip47LookupInvoiceResponse{}, + Result: &Nip47GetInfoResponse{}, } err = json.Unmarshal([]byte(decrypted), received) assert.NoError(t, err) - assert.Equal(t, mockInvoice, received.Result.(*Nip47LookupInvoiceResponse).Invoice) - assert.Equal(t, false, received.Result.(*Nip47LookupInvoiceResponse).Paid) + assert.Equal(t, mockNodeInfo.Alias, received.Result.(*Nip47GetInfoResponse).Alias) + assert.Equal(t, mockNodeInfo.Color, received.Result.(*Nip47GetInfoResponse).Color) + assert.Equal(t, mockNodeInfo.Pubkey, received.Result.(*Nip47GetInfoResponse).Pubkey) + assert.Equal(t, mockNodeInfo.Network, received.Result.(*Nip47GetInfoResponse).Network) + assert.Equal(t, mockNodeInfo.BlockHeight, received.Result.(*Nip47GetInfoResponse).BlockHeight) + assert.Equal(t, mockNodeInfo.BlockHash, received.Result.(*Nip47GetInfoResponse).BlockHash) + assert.Equal(t, []string{"get_info"}, received.Result.(*Nip47GetInfoResponse).Methods) } func createTestService(t *testing.T) (svc *Service, ln *MockLn) { @@ -444,6 +498,10 @@ func (mln *MockLn) GetBalance(ctx context.Context, senderPubkey string) (balance return 21, nil } +func (mln *MockLn) GetInfo(ctx context.Context, senderPubkey string) (info *NodeInfo, err error) { + return &mockNodeInfo, nil +} + func (mln *MockLn) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) { return mockInvoice, mockPaymentHash, nil }