diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml index 5cc3fe9..5e3f486 100644 --- a/.github/workflows/integration_tests.yaml +++ b/.github/workflows/integration_tests.yaml @@ -143,7 +143,8 @@ jobs: matrix: test: [ testLsps0GetProtocolVersions, - testLsps2GetVersions + testLsps2GetVersions, + testLsps2GetInfo ] implementation: [ CLN diff --git a/config/config.go b/config/config.go index 12c773a..aec1a58 100644 --- a/config/config.go +++ b/config/config.go @@ -75,6 +75,9 @@ type NodeConfig struct { // peer is offline. NotificationTimeout string `json:"notificationTimeout,string"` + MinPaymentSizeMsat uint64 `json:"minPaymentSizeMsat,string"` + MaxPaymentSizeMsat uint64 `json:"maxPaymentSizeMsat,string"` + // Set this field to connect to an LND node. Lnd *LndConfig `json:"lnd,omitempty"` diff --git a/itest/lspd_test.go b/itest/lspd_test.go index 914f4a9..2d5fc6e 100644 --- a/itest/lspd_test.go +++ b/itest/lspd_test.go @@ -182,4 +182,8 @@ var allTestCases = []*testCase{ name: "testLsps2GetVersions", test: testLsps2GetVersions, }, + { + name: "testLsps2GetInfo", + test: testLsps2GetInfo, + }, } diff --git a/itest/lsps2_get_info_test.go b/itest/lsps2_get_info_test.go new file mode 100644 index 0000000..0ffca85 --- /dev/null +++ b/itest/lsps2_get_info_test.go @@ -0,0 +1,70 @@ +package itest + +import ( + "encoding/hex" + "encoding/json" + "log" + "time" + + "github.com/breez/lntest" + "github.com/breez/lspd/lsps0" + "github.com/stretchr/testify/assert" +) + +func testLsps2GetInfo(p *testParams) { + SetFeeParams(p.Lsp(), []*FeeParamSetting{ + { + Validity: time.Second * 3600, + MinMsat: 3000000, + Proportional: 1000, + }, + { + Validity: time.Second * 2800, + MinMsat: 2000000, + Proportional: 800, + }, + }) + p.BreezClient().Node().ConnectPeer(p.Lsp().LightningNode()) + + rawMsg := `{ + "method": "lsps2.get_info", + "jsonrpc": "2.0", + "id": "example#3cad6a54d302edba4c9ade2f7ffac098", + "params": { + "version": 1, + "token": "hello" + } + }` + p.BreezClient().Node().SendCustomMessage(&lntest.CustomMsgRequest{ + PeerId: hex.EncodeToString(p.Lsp().NodeId()), + Type: lsps0.Lsps0MessageType, + Data: []byte(rawMsg), + }) + + resp := p.BreezClient().ReceiveCustomMessage() + log.Print(string(resp.Data)) + assert.Equal(p.t, uint32(37913), resp.Type) + + content := make(map[string]json.RawMessage) + err := json.Unmarshal(resp.Data[:], &content) + lntest.CheckError(p.t, err) + + result := make(map[string]json.RawMessage) + err = json.Unmarshal(content["result"], &result) + lntest.CheckError(p.t, err) + + menu := []*struct { + MinFeeMsat uint64 `json:"min_fee_msat,string"` + Proportional uint32 `json:"proportional"` + ValidUntil string `json:"valid_until"` + MinLifetime uint32 `json:"min_lifetime"` + MaxClientToSelfDelay uint32 `json:"max_client_to_self_delay"` + Promise string `json:"promise"` + }{} + err = json.Unmarshal(result["opening_fee_params_menu"], &menu) + lntest.CheckError(p.t, err) + + assert.Len(p.t, menu, 2) + assert.Equal(p.t, uint64(2000000), menu[0].MinFeeMsat) + assert.Equal(p.t, uint64(3000000), menu[1].MinFeeMsat) +} diff --git a/lsps2/server.go b/lsps2/server.go index f2fbac8..7326abb 100644 --- a/lsps2/server.go +++ b/lsps2/server.go @@ -2,24 +2,63 @@ package lsps2 import ( "context" + "log" "github.com/breez/lspd/lsps0" + "github.com/breez/lspd/lsps0/codes" + "github.com/breez/lspd/lsps0/status" + "github.com/breez/lspd/shared" ) +var SupportedVersion uint32 = 1 + type GetVersionsRequest struct { } type GetVersionsResponse struct { - Versions []int32 `json:"versions"` + Versions []uint32 `json:"versions"` +} + +type GetInfoRequest struct { + Version uint32 `json:"version"` + Token *string `json:"token,omitempty"` +} + +type GetInfoResponse struct { + OpeningFeeParamsMenu []*OpeningFeeParams `json:"opening_fee_params_menu"` + MinPaymentSizeMsat uint64 `json:"min_payment_size_msat,string"` + MaxPaymentSizeMsat uint64 `json:"max_payment_size_msat,string"` +} + +type OpeningFeeParams struct { + MinFeeMsat uint64 `json:"min_fee_msat,string"` + Proportional uint32 `json:"proportional"` + ValidUntil string `json:"valid_until"` + MinLifetime uint32 `json:"min_lifetime"` + MaxClientToSelfDelay uint32 `json:"max_client_to_self_delay"` + Promise string `json:"promise"` } type Lsps2Server interface { GetVersions(ctx context.Context, request *GetVersionsRequest) (*GetVersionsResponse, error) + GetInfo(ctx context.Context, request *GetInfoRequest) (*GetInfoResponse, error) +} +type server struct { + openingService shared.OpeningService + nodesService shared.NodesService + node *shared.Node } -type server struct{} -func NewLsps2Server() Lsps2Server { - return &server{} +func NewLsps2Server( + openingService shared.OpeningService, + nodesService shared.NodesService, + node *shared.Node, +) Lsps2Server { + return &server{ + openingService: openingService, + nodesService: nodesService, + node: node, + } } func (s *server) GetVersions( @@ -27,7 +66,64 @@ func (s *server) GetVersions( request *GetVersionsRequest, ) (*GetVersionsResponse, error) { return &GetVersionsResponse{ - Versions: []int32{1}, + Versions: []uint32{SupportedVersion}, + }, nil +} + +func (s *server) GetInfo( + ctx context.Context, + request *GetInfoRequest, +) (*GetInfoResponse, error) { + if request.Version != uint32(SupportedVersion) { + return nil, status.New(codes.Code(1), "unsupported_version").Err() + } + + if request.Token == nil || *request.Token == "" { + return nil, status.New(codes.Code(2), "unrecognized_or_stale_token").Err() + } + + node, err := s.nodesService.GetNode(*request.Token) + if err == shared.ErrNodeNotFound { + return nil, status.New(codes.Code(2), "unrecognized_or_stale_token").Err() + } + if err != nil { + log.Printf("Lsps2Server.GetInfo: nodesService.GetNode(%s) err: %v", *request.Token, err) + return nil, status.New(codes.InternalError, "internal error").Err() + } + if node.NodeConfig.NodePubkey != s.node.NodeConfig.NodePubkey { + log.Printf( + "Lsps2Server.GetInfo: Got token '%s' on node '%s', but was meant for node '%s'", + *request.Token, + s.node.NodeConfig.NodePubkey, + node.NodeConfig.NodePubkey, + ) + return nil, status.New(codes.Code(2), "unrecognized_or_stale_token").Err() + } + + m, err := s.openingService.GetFeeParamsMenu(*request.Token, node.PrivateKey) + if err == shared.ErrNodeNotFound { + return nil, status.New(codes.Code(2), "unrecognized_or_stale_token").Err() + } + if err != nil { + log.Printf("Lsps2Server.GetInfo: openingService.GetFeeParamsMenu(%s) err: %v", *request.Token, err) + return nil, status.New(codes.InternalError, "internal error").Err() + } + + menu := []*OpeningFeeParams{} + for _, p := range m { + menu = append(menu, &OpeningFeeParams{ + MinFeeMsat: p.MinFeeMsat, + Proportional: p.Proportional, + ValidUntil: p.ValidUntil, + MinLifetime: p.MinLifetime, + MaxClientToSelfDelay: p.MaxClientToSelfDelay, + Promise: p.Promise, + }) + } + return &GetInfoResponse{ + OpeningFeeParamsMenu: menu, + MinPaymentSizeMsat: node.NodeConfig.MinPaymentSizeMsat, + MaxPaymentSizeMsat: node.NodeConfig.MaxPaymentSizeMsat, }, nil } @@ -47,6 +143,16 @@ func RegisterLsps2Server(s lsps0.ServiceRegistrar, l Lsps2Server) { return srv.(Lsps2Server).GetVersions(ctx, in) }, }, + { + MethodName: "lsps2.get_info", + Handler: func(srv interface{}, ctx context.Context, dec func(interface{}) error) (interface{}, error) { + in := new(GetInfoRequest) + if err := dec(in); err != nil { + return nil, err + } + return srv.(Lsps2Server).GetInfo(ctx, in) + }, + }, }, }, l, diff --git a/lsps2/server_test.go b/lsps2/server_test.go new file mode 100644 index 0000000..e58b1ad --- /dev/null +++ b/lsps2/server_test.go @@ -0,0 +1,145 @@ +package lsps2 + +import ( + "context" + "testing" + + "github.com/breez/lspd/config" + "github.com/breez/lspd/lsps0/status" + "github.com/breez/lspd/shared" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/stretchr/testify/assert" +) + +type mockNodesService struct { + node *shared.Node + err error +} + +func (m *mockNodesService) GetNode(token string) (*shared.Node, error) { + return m.node, m.err +} + +func (m *mockNodesService) GetNodes() []*shared.Node { + return []*shared.Node{m.node} +} + +type mockOpeningService struct { + menu []*shared.OpeningFeeParams + err error + valid bool +} + +func (m *mockOpeningService) GetFeeParamsMenu( + token string, + privateKey *btcec.PrivateKey, +) ([]*shared.OpeningFeeParams, error) { + return m.menu, m.err +} + +func (m *mockOpeningService) ValidateOpeningFeeParams( + params *shared.OpeningFeeParams, + publicKey *btcec.PublicKey, +) bool { + return m.valid +} + +var token = "blah" +var node = &shared.Node{ + NodeConfig: &config.NodeConfig{ + MinPaymentSizeMsat: 123, + MaxPaymentSizeMsat: 456, + }, +} + +func Test_GetInfo_UnsupportedVersion(t *testing.T) { + n := &mockNodesService{} + o := &mockOpeningService{} + s := NewLsps2Server(o, n, nil) + _, err := s.GetInfo(context.Background(), &GetInfoRequest{ + Version: 2, + Token: &token, + }) + + st := status.Convert(err) + assert.Equal(t, uint32(1), uint32(st.Code)) + assert.Equal(t, "unsupported_version", st.Message) +} + +func Test_GetInfo_InvalidToken(t *testing.T) { + n := &mockNodesService{ + err: shared.ErrNodeNotFound, + } + o := &mockOpeningService{} + s := NewLsps2Server(o, n, nil) + _, err := s.GetInfo(context.Background(), &GetInfoRequest{ + Version: 1, + Token: &token, + }) + + st := status.Convert(err) + assert.Equal(t, uint32(2), uint32(st.Code)) + assert.Equal(t, "unrecognized_or_stale_token", st.Message) +} + +func Test_GetInfo_EmptyMenu(t *testing.T) { + n := &mockNodesService{node: node} + o := &mockOpeningService{menu: []*shared.OpeningFeeParams{}} + s := NewLsps2Server(o, n, node) + resp, err := s.GetInfo(context.Background(), &GetInfoRequest{ + Version: 1, + Token: &token, + }) + + assert.Nil(t, err) + assert.Equal(t, []*OpeningFeeParams{}, resp.OpeningFeeParamsMenu) + assert.Equal(t, node.NodeConfig.MinPaymentSizeMsat, resp.MinPaymentSizeMsat) + assert.Equal(t, node.NodeConfig.MaxPaymentSizeMsat, resp.MaxPaymentSizeMsat) +} + +func Test_GetInfo_PopulatedMenu_Ordered(t *testing.T) { + n := &mockNodesService{node: node} + o := &mockOpeningService{menu: []*shared.OpeningFeeParams{ + { + MinFeeMsat: 1, + Proportional: 2, + ValidUntil: "a", + MinLifetime: 3, + MaxClientToSelfDelay: 4, + Promise: "b", + }, + { + MinFeeMsat: 5, + Proportional: 6, + ValidUntil: "c", + MinLifetime: 7, + MaxClientToSelfDelay: 8, + Promise: "d", + }, + }} + s := NewLsps2Server(o, n, node) + resp, err := s.GetInfo(context.Background(), &GetInfoRequest{ + Version: 1, + Token: &token, + }) + + assert.Nil(t, err) + assert.Len(t, resp.OpeningFeeParamsMenu, 2) + + assert.Equal(t, uint64(1), resp.OpeningFeeParamsMenu[0].MinFeeMsat) + assert.Equal(t, uint32(2), resp.OpeningFeeParamsMenu[0].Proportional) + assert.Equal(t, "a", resp.OpeningFeeParamsMenu[0].ValidUntil) + assert.Equal(t, uint32(3), resp.OpeningFeeParamsMenu[0].MinLifetime) + assert.Equal(t, uint32(4), resp.OpeningFeeParamsMenu[0].MaxClientToSelfDelay) + assert.Equal(t, "b", resp.OpeningFeeParamsMenu[0].Promise) + + assert.Equal(t, uint64(5), resp.OpeningFeeParamsMenu[1].MinFeeMsat) + assert.Equal(t, uint32(6), resp.OpeningFeeParamsMenu[1].Proportional) + assert.Equal(t, "c", resp.OpeningFeeParamsMenu[1].ValidUntil) + assert.Equal(t, uint32(7), resp.OpeningFeeParamsMenu[1].MinLifetime) + assert.Equal(t, uint32(8), resp.OpeningFeeParamsMenu[1].MaxClientToSelfDelay) + assert.Equal(t, "d", resp.OpeningFeeParamsMenu[1].Promise) + + assert.Equal(t, node.NodeConfig.MinPaymentSizeMsat, resp.MinPaymentSizeMsat) + assert.Equal(t, node.NodeConfig.MaxPaymentSizeMsat, resp.MaxPaymentSizeMsat) +} diff --git a/main.go b/main.go index 3e50a4b..96ed15b 100644 --- a/main.go +++ b/main.go @@ -125,7 +125,7 @@ func main() { go msgClient.Start() msgServer := lsps0.NewServer() protocolServer := lsps0.NewProtocolServer([]uint32{2}) - lsps2Server := lsps2.NewLsps2Server() + lsps2Server := lsps2.NewLsps2Server(openingService, nodesService, node) lsps0.RegisterProtocolServer(msgServer, protocolServer) lsps2.RegisterLsps2Server(msgServer, lsps2Server) msgClient.WaitStarted()