From 2864710ba23a7e5db6e5b909fcc2a329410e96d0 Mon Sep 17 00:00:00 2001 From: Jesse de Wit Date: Thu, 17 Aug 2023 19:14:47 +0200 Subject: [PATCH] lsps2: implement lsps2.buy --- .github/workflows/integration_tests.yaml | 3 +- itest/lspd_test.go | 4 + itest/lsps2_buy_test.go | 88 +++++++++ lsps2/fee.go | 42 ++++ lsps2/fee_test.go | 41 ++++ lsps2/server.go | 135 +++++++++++++ lsps2/server_test.go | 237 +++++++++++++++++++++-- main.go | 3 +- 8 files changed, 532 insertions(+), 21 deletions(-) create mode 100644 itest/lsps2_buy_test.go create mode 100644 lsps2/fee.go create mode 100644 lsps2/fee_test.go diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml index c244bf0..f83eaf4 100644 --- a/.github/workflows/integration_tests.yaml +++ b/.github/workflows/integration_tests.yaml @@ -144,7 +144,8 @@ jobs: test: [ testLsps0GetProtocolVersions, testLsps2GetVersions, - testLsps2GetInfo + testLsps2GetInfo, + testLsps2Buy ] implementation: [ CLN diff --git a/itest/lspd_test.go b/itest/lspd_test.go index 1c2de6e..cfb02d9 100644 --- a/itest/lspd_test.go +++ b/itest/lspd_test.go @@ -190,4 +190,8 @@ var allTestCases = []*testCase{ name: "testLsps2GetInfo", test: testLsps2GetInfo, }, + { + name: "testLsps2Buy", + test: testLsps2Buy, + }, } diff --git a/itest/lsps2_buy_test.go b/itest/lsps2_buy_test.go new file mode 100644 index 0000000..b1d6e41 --- /dev/null +++ b/itest/lsps2_buy_test.go @@ -0,0 +1,88 @@ +package itest + +import ( + "encoding/hex" + "encoding/json" + "log" + "time" + + "github.com/breez/lntest" + "github.com/breez/lspd/lsps0" +) + +func testLsps2Buy(p *testParams) { + SetFeeParams(p.Lsp(), []*FeeParamSetting{ + { + Validity: time.Second * 3600, + MinMsat: 1000000, + Proportional: 1000, + }, + }) + p.BreezClient().Node().ConnectPeer(p.Lsp().LightningNode()) + + p.BreezClient().Node().SendCustomMessage(&lntest.CustomMsgRequest{ + PeerId: hex.EncodeToString(p.Lsp().NodeId()), + Type: lsps0.Lsps0MessageType, + Data: []byte(`{ + "method": "lsps2.get_info", + "jsonrpc": "2.0", + "id": "example#3cad6a54d302edba4c9ade2f7ffac098", + "params": { + "version": 1, + "token": "hello" + } + }`), + }) + + resp := p.BreezClient().ReceiveCustomMessage() + log.Print(string(resp.Data)) + + type params 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"` + } + data := new(struct { + Result struct { + Menu []params `json:"opening_fee_params_menu"` + MinPayment uint64 `json:"min_payment_size_msat,string"` + MaxPayment uint64 `json:"max_payment_size_msat,string"` + } `json:"result"` + }) + err := json.Unmarshal(resp.Data[:], &data) + lntest.CheckError(p.t, err) + + pr, err := json.Marshal(&data.Result.Menu[0]) + lntest.CheckError(p.t, err) + p.BreezClient().Node().SendCustomMessage(&lntest.CustomMsgRequest{ + PeerId: hex.EncodeToString(p.Lsp().NodeId()), + Type: lsps0.Lsps0MessageType, + Data: append(append( + []byte(`{ + "method": "lsps2.get_info", + "jsonrpc": "2.0", + "id": "example#3cad6a54d302edba4c9ade2f7ffac098", + "params": { + "version": 1, + "payment_size_msat": "42000", + "opening_fee_params":`), + pr...), + []byte(`}}`)..., + ), + }) + + buyResp := p.BreezClient().ReceiveCustomMessage() + log.Print(string(resp.Data)) + b := new(struct { + Result struct { + Jit_channel_scid string `json:"jit_channel_scid"` + Lsp_cltv_expiry_delta uint32 `json:"lsp_cltv_expiry_delta"` + Client_trusts_lsp bool `json:"client_trusts_lsp"` + } `json:"result"` + }) + err = json.Unmarshal(buyResp.Data, b) + lntest.CheckError(p.t, err) +} diff --git a/lsps2/fee.go b/lsps2/fee.go new file mode 100644 index 0000000..0bc4c01 --- /dev/null +++ b/lsps2/fee.go @@ -0,0 +1,42 @@ +package lsps2 + +import "fmt" + +var ErrOverflow = fmt.Errorf("integer overflow detected") + +func computeOpeningFee(paymentSizeMsat uint64, proportional uint32, minFeeMsat uint64) (uint64, error) { + tmp, err := safeMultiply(paymentSizeMsat, uint64(proportional)) + if err != nil { + return 0, err + } + + tmp, err = safeAdd(tmp, 999_999) + if err != nil { + return 0, err + } + + openingFee := tmp / 1_000_000 + if openingFee < minFeeMsat { + openingFee = minFeeMsat + } + + return openingFee, nil +} + +func safeAdd(a uint64, b uint64) (uint64, error) { + sum := a + b + if sum < a { + return 0, ErrOverflow + } + + return sum, nil +} + +func safeMultiply(a uint64, b uint64) (uint64, error) { + prod := a * b + if a != 0 && ((prod / a) != b) { + return 0, ErrOverflow + } + + return prod, nil +} diff --git a/lsps2/fee_test.go b/lsps2/fee_test.go new file mode 100644 index 0000000..dccaa5c --- /dev/null +++ b/lsps2/fee_test.go @@ -0,0 +1,41 @@ +package lsps2 + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_FeeVariant(t *testing.T) { + zero := uint64(0) + tests := []struct { + paymentSizeMsat uint64 + proportional uint32 + minFeeMsat uint64 + err error + expected uint64 + }{ + {proportional: 0, paymentSizeMsat: 0, minFeeMsat: 0, expected: 0}, + {proportional: 0, paymentSizeMsat: 0, minFeeMsat: 1, expected: 1}, + {proportional: 1, paymentSizeMsat: 1, minFeeMsat: 0, expected: 1}, + {proportional: 1000, paymentSizeMsat: 10_000_000, minFeeMsat: 9999, expected: 10000}, + {proportional: 1000, paymentSizeMsat: 10_000_000, minFeeMsat: 10000, expected: 10000}, + {proportional: 1000, paymentSizeMsat: 10_000_000, minFeeMsat: 10001, expected: 10001}, + {proportional: 1, paymentSizeMsat: zero - 999_999, minFeeMsat: 0, err: ErrOverflow}, + {proportional: 1, paymentSizeMsat: zero - 1_000_000, minFeeMsat: 0, expected: 18446744073709}, + {proportional: 2, paymentSizeMsat: zero - 1_000_000, minFeeMsat: 0, err: ErrOverflow}, + } + + for _, tst := range tests { + t.Run( + fmt.Sprintf("ps%d_prop%d_min%d", tst.paymentSizeMsat, tst.proportional, tst.minFeeMsat), + func(t *testing.T) { + res, err := computeOpeningFee(tst.paymentSizeMsat, tst.proportional, tst.minFeeMsat) + assert.Equal(t, tst.expected, res) + assert.Equal(t, tst.err, err) + }, + ) + + } +} diff --git a/lsps2/server.go b/lsps2/server.go index 7326abb..3826d07 100644 --- a/lsps2/server.go +++ b/lsps2/server.go @@ -39,25 +39,48 @@ type OpeningFeeParams struct { Promise string `json:"promise"` } +type BuyRequest struct { + Version uint32 `json:"version"` + OpeningFeeParams OpeningFeeParams `json:"opening_fee_params"` + PaymentSizeMsat *uint64 `json:"payment_size_msat,string,omitempty"` +} + +type BuyResponse struct { + JitChannelScid string `json:"jit_channel_scid"` + LspCltvExpiryDelta uint32 `json:"lsp_cltv_expiry_delta"` + ClientTrustsLsp bool `json:"client_trusts_lsp"` +} + type Lsps2Server interface { GetVersions(ctx context.Context, request *GetVersionsRequest) (*GetVersionsResponse, error) GetInfo(ctx context.Context, request *GetInfoRequest) (*GetInfoResponse, error) + Buy(ctx context.Context, request *BuyRequest) (*BuyResponse, error) } type server struct { openingService shared.OpeningService nodesService shared.NodesService node *shared.Node + store Lsps2Store } +type OpeningMode int + +const ( + OpeningMode_NoMppVarInvoice OpeningMode = 1 + OpeningMode_MppFixedInvoice OpeningMode = 2 +) + func NewLsps2Server( openingService shared.OpeningService, nodesService shared.NodesService, node *shared.Node, + store Lsps2Store, ) Lsps2Server { return &server{ openingService: openingService, nodesService: nodesService, node: node, + store: store, } } @@ -127,6 +150,108 @@ func (s *server) GetInfo( }, nil } +func (s *server) Buy( + ctx context.Context, + request *BuyRequest, +) (*BuyResponse, error) { + if request.Version != uint32(SupportedVersion) { + return nil, status.New(codes.Code(1), "unsupported_version").Err() + } + + params := &shared.OpeningFeeParams{ + MinFeeMsat: request.OpeningFeeParams.MinFeeMsat, + Proportional: request.OpeningFeeParams.Proportional, + ValidUntil: request.OpeningFeeParams.ValidUntil, + MinLifetime: request.OpeningFeeParams.MinLifetime, + MaxClientToSelfDelay: request.OpeningFeeParams.MaxClientToSelfDelay, + Promise: request.OpeningFeeParams.Promise, + } + paramsValid := s.openingService.ValidateOpeningFeeParams( + params, + s.node.PublicKey, + ) + if !paramsValid { + return nil, status.New(codes.Code(2), "invalid_opening_fee_params").Err() + } + + var mode OpeningMode + if request.PaymentSizeMsat == nil || *request.PaymentSizeMsat == 0 { + mode = OpeningMode_NoMppVarInvoice + } else { + mode = OpeningMode_MppFixedInvoice + if *request.PaymentSizeMsat < s.node.NodeConfig.MinPaymentSizeMsat { + return nil, status.New(codes.Code(3), "payment_size_too_small").Err() + } + if *request.PaymentSizeMsat > s.node.NodeConfig.MaxPaymentSizeMsat { + return nil, status.New(codes.Code(4), "payment_size_too_large").Err() + } + + openingFee, err := computeOpeningFee( + *request.PaymentSizeMsat, + request.OpeningFeeParams.Proportional, + request.OpeningFeeParams.MinFeeMsat, + ) + if err == ErrOverflow { + return nil, status.New(codes.Code(4), "payment_size_too_large").Err() + } + if err != nil { + log.Printf( + "Lsps2Server.Buy: computeOpeningFee(%d, %d, %d) err: %v", + *request.PaymentSizeMsat, + request.OpeningFeeParams.Proportional, + request.OpeningFeeParams.MinFeeMsat, + err, + ) + return nil, status.New(codes.InternalError, "internal error").Err() + } + + if openingFee >= *request.PaymentSizeMsat { + return nil, status.New(codes.Code(3), "payment_size_too_small").Err() + } + + // NOTE: There's an option here to check for sufficient inbound liquidity as well. + } + + // TODO: Restrict buying only one channel at a time? + peerId, ok := ctx.Value(lsps0.PeerContextKey).(string) + if !ok { + log.Printf("Lsps2Server.Buy: Error: No peer id found on context.") + return nil, status.New(codes.InternalError, "internal error").Err() + } + + // Note, the odds to generate an already existing scid is about 10e-9 if you + // have 100_000 channels with aliases. And about 10e-11 for 10_000 channels. + // We call that negligable, and we'll assume the generated scid is unique. + // (to calculate the odds, use the birthday paradox) + scid, err := newScid() + if err != nil { + log.Printf("Lsps2Server.Buy: error generating new scid err: %v", err) + return nil, status.New(codes.InternalError, "internal error").Err() + } + + // RegisterBuy errors if the scid is not unique. But the node could have + // 'actual' scids to collide with the newly generated scid. These are not in + // our database. + err = s.store.RegisterBuy(ctx, &RegisterBuy{ + LspId: s.node.NodeConfig.NodePubkey, + PeerId: peerId, + Scid: *scid, + OpeningFeeParams: *params, + PaymentSizeMsat: request.PaymentSizeMsat, + Mode: mode, + }) + if err != nil { + log.Printf("Lsps2Server.Buy: store.RegisterBuy err: %v", err) + return nil, status.New(codes.InternalError, "internal error").Err() + } + + return &BuyResponse{ + JitChannelScid: scid.ToString(), + LspCltvExpiryDelta: s.node.NodeConfig.TimeLockDelta, + ClientTrustsLsp: false, + }, nil +} + func RegisterLsps2Server(s lsps0.ServiceRegistrar, l Lsps2Server) { s.RegisterService( &lsps0.ServiceDesc{ @@ -153,6 +278,16 @@ func RegisterLsps2Server(s lsps0.ServiceRegistrar, l Lsps2Server) { return srv.(Lsps2Server).GetInfo(ctx, in) }, }, + { + MethodName: "lsps2.buy", + Handler: func(srv interface{}, ctx context.Context, dec func(interface{}) error) (interface{}, error) { + in := new(BuyRequest) + if err := dec(in); err != nil { + return nil, err + } + return srv.(Lsps2Server).Buy(ctx, in) + }, + }, }, }, l, diff --git a/lsps2/server_test.go b/lsps2/server_test.go index e58b1ad..3bb5872 100644 --- a/lsps2/server_test.go +++ b/lsps2/server_test.go @@ -2,9 +2,11 @@ package lsps2 import ( "context" + "fmt" "testing" "github.com/breez/lspd/config" + "github.com/breez/lspd/lsps0" "github.com/breez/lspd/lsps0/status" "github.com/breez/lspd/shared" "github.com/btcsuite/btcd/btcec/v2" @@ -25,9 +27,9 @@ func (m *mockNodesService) GetNodes() []*shared.Node { } type mockOpeningService struct { - menu []*shared.OpeningFeeParams - err error - valid bool + menu []*shared.OpeningFeeParams + err error + invalid bool } func (m *mockOpeningService) GetFeeParamsMenu( @@ -41,29 +43,43 @@ func (m *mockOpeningService) ValidateOpeningFeeParams( params *shared.OpeningFeeParams, publicKey *btcec.PublicKey, ) bool { - return m.valid + return !m.invalid +} + +type mockLsps2Store struct { + err error + req *RegisterBuy +} + +func (s *mockLsps2Store) RegisterBuy(ctx context.Context, req *RegisterBuy) error { + s.req = req + return s.err } var token = "blah" -var node = &shared.Node{ - NodeConfig: &config.NodeConfig{ - MinPaymentSizeMsat: 123, - MaxPaymentSizeMsat: 456, - }, +var node = func() *shared.Node { + return &shared.Node{ + NodeConfig: &config.NodeConfig{ + MinPaymentSizeMsat: 1000, + MaxPaymentSizeMsat: 10000, + TimeLockDelta: 143, + }, + } } func Test_GetInfo_UnsupportedVersion(t *testing.T) { n := &mockNodesService{} o := &mockOpeningService{} - s := NewLsps2Server(o, n, nil) + st := &mockLsps2Store{} + s := NewLsps2Server(o, n, nil, st) _, 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) + status := status.Convert(err) + assert.Equal(t, uint32(1), uint32(status.Code)) + assert.Equal(t, "unsupported_version", status.Message) } func Test_GetInfo_InvalidToken(t *testing.T) { @@ -71,21 +87,24 @@ func Test_GetInfo_InvalidToken(t *testing.T) { err: shared.ErrNodeNotFound, } o := &mockOpeningService{} - s := NewLsps2Server(o, n, nil) + st := &mockLsps2Store{} + s := NewLsps2Server(o, n, nil, st) _, 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) + status := status.Convert(err) + assert.Equal(t, uint32(2), uint32(status.Code)) + assert.Equal(t, "unrecognized_or_stale_token", status.Message) } func Test_GetInfo_EmptyMenu(t *testing.T) { + node := node() n := &mockNodesService{node: node} o := &mockOpeningService{menu: []*shared.OpeningFeeParams{}} - s := NewLsps2Server(o, n, node) + st := &mockLsps2Store{} + s := NewLsps2Server(o, n, node, st) resp, err := s.GetInfo(context.Background(), &GetInfoRequest{ Version: 1, Token: &token, @@ -98,6 +117,7 @@ func Test_GetInfo_EmptyMenu(t *testing.T) { } func Test_GetInfo_PopulatedMenu_Ordered(t *testing.T) { + node := node() n := &mockNodesService{node: node} o := &mockOpeningService{menu: []*shared.OpeningFeeParams{ { @@ -117,7 +137,8 @@ func Test_GetInfo_PopulatedMenu_Ordered(t *testing.T) { Promise: "d", }, }} - s := NewLsps2Server(o, n, node) + st := &mockLsps2Store{} + s := NewLsps2Server(o, n, node, st) resp, err := s.GetInfo(context.Background(), &GetInfoRequest{ Version: 1, Token: &token, @@ -143,3 +164,181 @@ func Test_GetInfo_PopulatedMenu_Ordered(t *testing.T) { assert.Equal(t, node.NodeConfig.MinPaymentSizeMsat, resp.MinPaymentSizeMsat) assert.Equal(t, node.NodeConfig.MaxPaymentSizeMsat, resp.MaxPaymentSizeMsat) } + +func Test_Buy_UnsupportedVersion(t *testing.T) { + n := &mockNodesService{} + o := &mockOpeningService{} + st := &mockLsps2Store{} + s := NewLsps2Server(o, n, nil, st) + _, err := s.Buy(context.Background(), &BuyRequest{ + Version: 2, + }) + + status := status.Convert(err) + assert.Equal(t, uint32(1), uint32(status.Code)) + assert.Equal(t, "unsupported_version", status.Message) +} + +func Test_Buy_InvalidFeeParams(t *testing.T) { + node := node() + n := &mockNodesService{} + o := &mockOpeningService{ + invalid: true, + } + st := &mockLsps2Store{} + s := NewLsps2Server(o, n, node, st) + _, err := s.Buy(context.Background(), &BuyRequest{ + Version: 1, + OpeningFeeParams: OpeningFeeParams{ + MinFeeMsat: 1, + Proportional: 2, + ValidUntil: "2023-08-18T13:39:00.000Z", + MinLifetime: 3, + MaxClientToSelfDelay: 4, + Promise: "fake", + }, + }) + + status := status.Convert(err) + assert.Equal(t, uint32(2), uint32(status.Code)) + assert.Equal(t, "invalid_opening_fee_params", status.Message) +} + +func Test_Buy_PaymentSize(t *testing.T) { + tests := []struct { + minFeeMsat uint64 + paymentSize uint64 + success bool + code uint32 + message string + }{ + { + minFeeMsat: 0, + paymentSize: 999, + success: false, + code: 3, + message: "payment_size_too_small", + }, + { + minFeeMsat: 0, + paymentSize: 1000, + success: true, + }, + { + minFeeMsat: 0, + paymentSize: 1001, + success: true, + }, + { + minFeeMsat: 0, + paymentSize: 9999, + success: true, + }, + { + minFeeMsat: 0, + paymentSize: 10000, + success: true, + }, + { + minFeeMsat: 0, + paymentSize: 10001, + success: false, + code: 4, + message: "payment_size_too_large", + }, + { + minFeeMsat: 2000, + paymentSize: 1999, + success: false, + code: 3, + message: "payment_size_too_small", + }, + { + minFeeMsat: 2000, + paymentSize: 2000, + success: false, + code: 3, + message: "payment_size_too_small", + }, + { + minFeeMsat: 2000, + paymentSize: 2001, + success: true, + }, + } + + for _, c := range tests { + t.Run( + fmt.Sprintf("paymentsize_%d", c.paymentSize), + func(t *testing.T) { + node := node() + n := &mockNodesService{} + o := &mockOpeningService{} + st := &mockLsps2Store{} + s := NewLsps2Server(o, n, node, st) + ctx := context.WithValue(context.Background(), lsps0.PeerContextKey, "peer id") + _, err := s.Buy(ctx, &BuyRequest{ + Version: 1, + OpeningFeeParams: OpeningFeeParams{ + MinFeeMsat: c.minFeeMsat, + Proportional: 2, + ValidUntil: "2023-08-18T13:39:00.000Z", + MinLifetime: 3, + MaxClientToSelfDelay: 4, + Promise: "fake", + }, + PaymentSizeMsat: &c.paymentSize, + }) + + if c.success { + assert.NoError(t, err) + } else { + assert.Error(t, err) + status := status.Convert(err) + assert.Equal(t, uint32(c.code), uint32(status.Code)) + assert.Equal(t, c.message, status.Message) + } + }, + ) + } +} + +func Test_Buy_Registered(t *testing.T) { + node := node() + n := &mockNodesService{} + o := &mockOpeningService{} + st := &mockLsps2Store{} + s := NewLsps2Server(o, n, node, st) + paymentSize := uint64(1000) + peerid := "peer id" + ctx := context.WithValue(context.Background(), lsps0.PeerContextKey, peerid) + resp, _ := s.Buy(ctx, &BuyRequest{ + Version: 1, + OpeningFeeParams: OpeningFeeParams{ + MinFeeMsat: 1, + Proportional: 2, + ValidUntil: "2023-08-18T13:39:00.000Z", + MinLifetime: 3, + MaxClientToSelfDelay: 4, + Promise: "fake", + }, + PaymentSizeMsat: &paymentSize, + }) + + assert.NotNil(t, st.req) + assert.Equal(t, node.NodeConfig.NodePubkey, st.req.LspId) + assert.Equal(t, peerid, st.req.PeerId) + assert.Equal(t, OpeningMode_MppFixedInvoice, st.req.Mode) + assert.Equal(t, &paymentSize, st.req.PaymentSizeMsat) + assert.NotZero(t, uint64(st.req.Scid)) + assert.Equal(t, uint64(1), st.req.OpeningFeeParams.MinFeeMsat) + assert.Equal(t, uint32(2), st.req.OpeningFeeParams.Proportional) + assert.Equal(t, "2023-08-18T13:39:00.000Z", st.req.OpeningFeeParams.ValidUntil) + assert.Equal(t, uint32(3), st.req.OpeningFeeParams.MinLifetime) + assert.Equal(t, uint32(4), st.req.OpeningFeeParams.MaxClientToSelfDelay) + assert.Equal(t, "fake", st.req.OpeningFeeParams.Promise) + + assert.Equal(t, st.req.Scid.ToString(), resp.JitChannelScid) + assert.Equal(t, false, resp.ClientTrustsLsp) + assert.Equal(t, uint32(143), resp.LspCltvExpiryDelta) +} diff --git a/main.go b/main.go index 96ed15b..271ec7d 100644 --- a/main.go +++ b/main.go @@ -87,6 +87,7 @@ func main() { interceptStore := postgresql.NewPostgresInterceptStore(pool) forwardingStore := postgresql.NewForwardingEventStore(pool) notificationsStore := postgresql.NewNotificationsStore(pool) + lsps2Store := postgresql.NewLsps2Store(pool) notificationService := notifications.NewNotificationService(notificationsStore) openingService := shared.NewOpeningService(interceptStore, nodesService) @@ -125,7 +126,7 @@ func main() { go msgClient.Start() msgServer := lsps0.NewServer() protocolServer := lsps0.NewProtocolServer([]uint32{2}) - lsps2Server := lsps2.NewLsps2Server(openingService, nodesService, node) + lsps2Server := lsps2.NewLsps2Server(openingService, nodesService, node, lsps2Store) lsps0.RegisterProtocolServer(msgServer, protocolServer) lsps2.RegisterLsps2Server(msgServer, lsps2Server) msgClient.WaitStarted()