From 3cce7b8b35705b2ed424964f03d3dd463f488f42 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 27 Mar 2019 15:53:06 +0900 Subject: [PATCH] Refactor the lightning listener, some users complain payments are not detected (should fix #676) --- BTCPayServer.Tests/ServerTester.cs | 7 +- BTCPayServer.Tests/UnitTest1.cs | 6 +- .../Payments/Lightning/LightningListener.cs | 462 ++++++++++-------- 3 files changed, 263 insertions(+), 212 deletions(-) diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs index db8e71914..0b75db473 100644 --- a/BTCPayServer.Tests/ServerTester.cs +++ b/BTCPayServer.Tests/ServerTester.cs @@ -23,6 +23,7 @@ using BTCPayServer.Payments.Lightning; using BTCPayServer.Lightning.CLightning; using BTCPayServer.Lightning; using BTCPayServer.Services; +using BTCPayServer.Tests.Logging; namespace BTCPayServer.Tests { @@ -85,9 +86,11 @@ namespace BTCPayServer.Tests /// Connect a customer LN node to the merchant LN node /// /// - public Task EnsureChannelsSetup() + public async Task EnsureChannelsSetup() { - return BTCPayServer.Lightning.Tests.ConnectChannels.ConnectAll(ExplorerNode, GetLightningSenderClients(), GetLightningDestClients()); + Logs.Tester.LogInformation("Connecting channels"); + await BTCPayServer.Lightning.Tests.ConnectChannels.ConnectAll(ExplorerNode, GetLightningSenderClients(), GetLightningDestClients()).ConfigureAwait(false); + Logs.Tester.LogInformation("Channels connected"); } private IEnumerable GetLightningSenderClients() diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 46c263829..b4753350e 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -462,14 +462,14 @@ namespace BTCPayServer.Tests } } - [Fact] + [Fact(Timeout = 60 * 1000)] [Trait("Integration", "Integration")] public async Task CanSendLightningPaymentCLightning() { await ProcessLightningPayment(LightningConnectionType.CLightning); } - [Fact] + [Fact(Timeout = 60 * 1000)] [Trait("Integration", "Integration")] public async Task CanSendLightningPaymentCharge() { @@ -516,7 +516,9 @@ namespace BTCPayServer.Tests ItemDesc = "Some description" }); await Task.Delay(TimeSpan.FromMilliseconds(1000)); // Give time to listen the new invoices + Logs.Tester.LogInformation($"Trying to send Lightning payment to {invoice.Id}"); await tester.SendLightningPaymentAsync(invoice); + Logs.Tester.LogInformation($"Lightning payment to {invoice.Id} is sent"); await TestUtils.EventuallyAsync(async () => { var localInvoice = await user.BitPay.GetInvoiceAsync(invoice.Id); diff --git a/BTCPayServer/Payments/Lightning/LightningListener.cs b/BTCPayServer/Payments/Lightning/LightningListener.cs index bfcd93b53..3bede145e 100644 --- a/BTCPayServer/Payments/Lightning/LightningListener.cs +++ b/BTCPayServer/Payments/Lightning/LightningListener.cs @@ -10,129 +10,144 @@ using BTCPayServer.Services.Invoices; using Microsoft.Extensions.Hosting; using NBXplorer; using BTCPayServer.Lightning; +using System.Collections.Concurrent; +using System.Threading.Channels; +using Microsoft.Extensions.Caching.Memory; namespace BTCPayServer.Payments.Lightning { public class LightningListener : IHostedService { - class ListenedInvoice - { - public LightningLikePaymentMethodDetails PaymentMethodDetails { get; set; } - public LightningSupportedPaymentMethod SupportedPaymentMethod { get; set; } - public PaymentMethod PaymentMethod { get; set; } - public string Uri { get; internal set; } - public BTCPayNetwork Network { get; internal set; } - public string InvoiceId { get; internal set; } - } - EventAggregator _Aggregator; InvoiceRepository _InvoiceRepository; + private readonly IMemoryCache _memoryCache; BTCPayNetworkProvider _NetworkProvider; + Channel _CheckInvoices = Channel.CreateUnbounded(); + Task _CheckingInvoice; + Dictionary<(string, string), LightningInstanceListener> _InstanceListeners = new Dictionary<(string, string), LightningInstanceListener>(); + public LightningListener(EventAggregator aggregator, InvoiceRepository invoiceRepository, + IMemoryCache memoryCache, BTCPayNetworkProvider networkProvider) { _Aggregator = aggregator; _InvoiceRepository = invoiceRepository; + _memoryCache = memoryCache; _NetworkProvider = networkProvider; } + async Task CheckingInvoice(CancellationToken cancellation) + { + while(await _CheckInvoices.Reader.WaitToReadAsync(cancellation) && + _CheckInvoices.Reader.TryRead(out var invoiceId)) + { + try + { + foreach (var listenedInvoice in (await GetListenedInvoices(invoiceId)).Where(i => !i.IsExpired())) + { + var instanceListenerKey = (listenedInvoice.Network.CryptoCode, listenedInvoice.SupportedPaymentMethod.GetLightningUrl().ToString()); + if (!_InstanceListeners.TryGetValue(instanceListenerKey, out var instanceListener) || + !instanceListener.IsListening) + { + instanceListener = instanceListener ?? new LightningInstanceListener(_InvoiceRepository, _Aggregator, listenedInvoice.SupportedPaymentMethod, listenedInvoice.Network); + var status = await instanceListener.PollPayment(listenedInvoice, cancellation); + if (status is null || + status is LightningInvoiceStatus.Paid || + status is LightningInvoiceStatus.Expired) + { + continue; + } + instanceListener.AddListenedInvoice(listenedInvoice); + instanceListener.EnsureListening(cancellation); + _InstanceListeners.TryAdd(instanceListenerKey, instanceListener); + } + else + { + instanceListener.AddListenedInvoice(listenedInvoice); + } + } + foreach (var kv in _InstanceListeners) + { + kv.Value.RemoveExpiredInvoices(); + } + foreach (var k in _InstanceListeners + .Where(kv => !kv.Value.IsListening) + .Select(kv => kv.Key).ToArray()) + { + _InstanceListeners.Remove(k); + } + } + catch when (!_Cts.Token.IsCancellationRequested) + { + + } + } + } + private Task> GetListenedInvoices(string invoiceId) + { + return _memoryCache.GetOrCreateAsync(invoiceId, async (cacheEntry) => + { + var listenedInvoices = new List(); + var invoice = await _InvoiceRepository.GetInvoice(invoiceId); + foreach (var paymentMethod in invoice.GetPaymentMethods(_NetworkProvider) + .Where(c => c.GetId().PaymentType == PaymentTypes.LightningLike)) + { + var lightningMethod = paymentMethod.GetPaymentMethodDetails() as LightningLikePaymentMethodDetails; + if (lightningMethod == null) + continue; + var lightningSupportedMethod = invoice.GetSupportedPaymentMethod(_NetworkProvider) + .FirstOrDefault(c => c.CryptoCode == paymentMethod.GetId().CryptoCode); + if (lightningSupportedMethod == null) + continue; + var network = _NetworkProvider.GetNetwork(paymentMethod.GetId().CryptoCode); + + listenedInvoices.Add(new ListenedInvoice() + { + Expiration = invoice.ExpirationTime, + Uri = lightningSupportedMethod.GetLightningUrl().BaseUri.AbsoluteUri, + PaymentMethodDetails = lightningMethod, + SupportedPaymentMethod = lightningSupportedMethod, + PaymentMethod = paymentMethod, + Network = network, + InvoiceId = invoice.Id + }); + } + var expiredIn = DateTimeOffset.UtcNow - invoice.ExpirationTime; + cacheEntry.AbsoluteExpiration = DateTimeOffset.UtcNow + (expiredIn >= TimeSpan.FromMinutes(5.0) ? expiredIn : TimeSpan.FromMinutes(5.0)); + return listenedInvoices; + }); + } + + ConcurrentDictionary _ListeningInstances = new ConcurrentDictionary(); + + CompositeDisposable leases = new CompositeDisposable(); public Task StartAsync(CancellationToken cancellationToken) { - leases.Add(_Aggregator.Subscribe(async inv => + leases.Add(_Aggregator.Subscribe(inv => { if (inv.Name == InvoiceEvent.Created) { - await EnsureListening(inv.Invoice.Id, false); + _CheckInvoices.Writer.TryWrite(inv.Invoice.Id); } })); - + _CheckingInvoice = CheckingInvoice(_Cts.Token); _ListenPoller = new Timer(async s => { try { - await Task.WhenAll((await _InvoiceRepository.GetPendingInvoices()) - .Select(async invoiceId => await EnsureListening(invoiceId, true)) - .ToArray()); - } - catch (AggregateException ex) - { - Logs.PayServer.LogError(ex.InnerException ?? ex.InnerExceptions.FirstOrDefault(), $"Lightning: Uncaught error"); - } - catch (Exception ex) - { - Logs.PayServer.LogError(ex, $"Lightning: Uncaught error"); + var invoiceIds = await _InvoiceRepository.GetPendingInvoices(); + foreach (var invoiceId in invoiceIds) + _CheckInvoices.Writer.TryWrite(invoiceId); } + catch { } // Never throw an unhandled exception on async void + }, null, 0, (int)PollInterval.TotalMilliseconds); leases.Add(_ListenPoller); return Task.CompletedTask; } - private async Task EnsureListening(string invoiceId, bool poll) - { - if (Listening(invoiceId)) - return; - var invoice = await _InvoiceRepository.GetInvoice(invoiceId); - foreach (var paymentMethod in invoice.GetPaymentMethods(_NetworkProvider) - .Where(c => c.GetId().PaymentType == PaymentTypes.LightningLike)) - { - var lightningMethod = paymentMethod.GetPaymentMethodDetails() as LightningLikePaymentMethodDetails; - if (lightningMethod == null) - continue; - var lightningSupportedMethod = invoice.GetSupportedPaymentMethod(_NetworkProvider) - .FirstOrDefault(c => c.CryptoCode == paymentMethod.GetId().CryptoCode); - if (lightningSupportedMethod == null) - continue; - var network = _NetworkProvider.GetNetwork(paymentMethod.GetId().CryptoCode); - - var listenedInvoice = new ListenedInvoice() - { - Uri = lightningSupportedMethod.GetLightningUrl().BaseUri.AbsoluteUri, - PaymentMethodDetails = lightningMethod, - SupportedPaymentMethod = lightningSupportedMethod, - PaymentMethod = paymentMethod, - Network = network, - InvoiceId = invoice.Id - }; - - if (poll) - { - var charge = lightningSupportedMethod.CreateClient(network); - LightningInvoice chargeInvoice = null; - string errorMessage = $"{lightningSupportedMethod.CryptoCode} (Lightning): Can't connect to the lightning server"; - try - { - chargeInvoice = await charge.GetInvoice(lightningMethod.InvoiceId); - } - catch (System.Net.Sockets.SocketException socketEx) when (socketEx.SocketErrorCode == System.Net.Sockets.SocketError.ConnectionRefused) - { - Logs.PayServer.LogError(errorMessage); - continue; - } - catch (Exception ex) when (ex.InnerException is System.Net.Sockets.SocketException socketEx - && socketEx.SocketErrorCode == System.Net.Sockets.SocketError.ConnectionRefused) - { - Logs.PayServer.LogError(errorMessage); - continue; - } - catch (Exception ex) - { - Logs.PayServer.LogError(ex, errorMessage); - continue; - } - if (chargeInvoice == null) - continue; - if (chargeInvoice.Status == LightningInvoiceStatus.Paid) - await AddPayment(network, chargeInvoice, listenedInvoice); - if (chargeInvoice.Status == LightningInvoiceStatus.Paid || chargeInvoice.Status == LightningInvoiceStatus.Expired) - continue; - } - - StartListening(listenedInvoice); - } - } - TimeSpan _PollInterval = TimeSpan.FromMinutes(1.0); public TimeSpan PollInterval { @@ -151,56 +166,155 @@ namespace BTCPayServer.Payments.Lightning } CancellationTokenSource _Cts = new CancellationTokenSource(); - private async Task Listen(LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network) + + HashSet _InvoiceIds = new HashSet(); + private Timer _ListenPoller; + + public async Task StopAsync(CancellationToken cancellationToken) { - ILightningInvoiceListener session = null; + leases.Dispose(); + _Cts.Cancel(); + try + { + await _CheckingInvoice; + } + catch (OperationCanceledException) + { + + } + try + { + await Task.WhenAll(_ListeningInstances.Select(c => c.Value.Listening).ToArray()); + } + catch (OperationCanceledException) + { + + } + Logs.PayServer.LogInformation("Lightning listened stopped"); + } + } + + + public class LightningInstanceListener + { + private LightningSupportedPaymentMethod supportedPaymentMethod; + private readonly InvoiceRepository invoiceRepository; + private readonly EventAggregator _eventAggregator; + private readonly BTCPayNetwork network; + + public LightningInstanceListener(InvoiceRepository invoiceRepository, + EventAggregator eventAggregator, + LightningSupportedPaymentMethod supportedPaymentMethod, + BTCPayNetwork network) + { + this.supportedPaymentMethod = supportedPaymentMethod; + this.invoiceRepository = invoiceRepository; + _eventAggregator = eventAggregator; + this.network = network; + } + internal bool AddListenedInvoice(ListenedInvoice invoice) + { + return _ListenedInvoices.TryAdd(invoice.PaymentMethodDetails.InvoiceId, invoice); + } + + internal async Task PollPayment(ListenedInvoice listenedInvoice, CancellationToken cancellation) + { + var client = supportedPaymentMethod.CreateClient(network); + LightningInvoice lightningInvoice = await client.GetInvoice(listenedInvoice.PaymentMethodDetails.InvoiceId); + if (lightningInvoice?.Status is LightningInvoiceStatus.Paid && + await AddPayment(lightningInvoice, listenedInvoice.InvoiceId)) + { + Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Payment detected via polling on {listenedInvoice.InvoiceId}"); + } + return lightningInvoice?.Status; + } + + public bool IsListening => Listening?.Status is TaskStatus.Running || Listening?.Status is TaskStatus.WaitingForActivation; + public Task Listening { get; set; } + public void EnsureListening(CancellationToken cancellation) + { + if (!IsListening) + { + StopListeningCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + Listening = Listen(StopListeningCancellationTokenSource.Token); + } + } + public CancellationTokenSource StopListeningCancellationTokenSource; + async Task Listen(CancellationToken cancellation) + { + Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Start listening {supportedPaymentMethod.GetLightningUrl().BaseUri}"); try { - Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Start listening {supportedPaymentMethod.GetLightningUrl().BaseUri}"); var lightningClient = supportedPaymentMethod.CreateClient(network); - session = await lightningClient.Listen(_Cts.Token); - while (true) + using (var session = await lightningClient.Listen(cancellation)) { - var notification = await session.WaitInvoice(_Cts.Token); - ListenedInvoice listenedInvoice = GetListenedInvoice(notification.Id); - if (listenedInvoice == null) - continue; - if (notification.Id == listenedInvoice.PaymentMethodDetails.InvoiceId && - notification.BOLT11 == listenedInvoice.PaymentMethodDetails.BOLT11) + // Just in case the payment arrived after our last poll but before we listened. + await PollAllListenedInvoices(cancellation); + if (_ErrorAlreadyLogged) { - if (notification.Status == LightningInvoiceStatus.Paid && - notification.PaidAt.HasValue && notification.Amount != null) + Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Could reconnect successfully to {supportedPaymentMethod.GetLightningUrl().BaseUri}"); + } + _ErrorAlreadyLogged = false; + while (!_ListenedInvoices.IsEmpty) + { + var notification = await session.WaitInvoice(cancellation); + if (!_ListenedInvoices.TryGetValue(notification.Id, out var listenedInvoice)) + continue; + if (notification.Id == listenedInvoice.PaymentMethodDetails.InvoiceId && + notification.BOLT11 == listenedInvoice.PaymentMethodDetails.BOLT11) { - await AddPayment(network, notification, listenedInvoice); - if (DoneListening(listenedInvoice)) - break; - } - if (notification.Status == LightningInvoiceStatus.Expired) - { - if (DoneListening(listenedInvoice)) - break; + if (notification.Status == LightningInvoiceStatus.Paid && + notification.PaidAt.HasValue && notification.Amount != null) + { + if (await AddPayment(notification, listenedInvoice.InvoiceId)) + { + Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Payment detected via notification ({listenedInvoice.InvoiceId})"); + } + _ListenedInvoices.TryRemove(notification.Id, out var _); + } + else if (notification.Status == LightningInvoiceStatus.Expired) + { + _ListenedInvoices.TryRemove(notification.Id, out var _); + } } } } } - catch when (_Cts.IsCancellationRequested) - { - } - catch (Exception ex) + catch (Exception ex) when (!cancellation.IsCancellationRequested && !_ErrorAlreadyLogged) { + _ErrorAlreadyLogged = true; Logs.PayServer.LogError(ex, $"{supportedPaymentMethod.CryptoCode} (Lightning): Error while contacting {supportedPaymentMethod.GetLightningUrl().BaseUri}"); - DoneListening(supportedPaymentMethod.GetLightningUrl()); + Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Stop listening {supportedPaymentMethod.GetLightningUrl().BaseUri}"); } - finally + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { } + if (_ListenedInvoices.IsEmpty) + Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): No more invoice to listen on {supportedPaymentMethod.GetLightningUrl().BaseUri}, releasing the connection."); + } + public DateTimeOffset? LastFullPoll { get; set; } + + internal async Task PollAllListenedInvoices(CancellationToken cancellation) + { + foreach (var invoice in _ListenedInvoices.Values) { - session?.Dispose(); + var status = await PollPayment(invoice, cancellation); + if (status is null || + status is LightningInvoiceStatus.Paid || + status is LightningInvoiceStatus.Expired) + _ListenedInvoices.TryRemove(invoice.PaymentMethodDetails.InvoiceId, out var _); + } + LastFullPoll = DateTimeOffset.UtcNow; + if (_ListenedInvoices.IsEmpty) + { + StopListeningCancellationTokenSource?.Cancel(); } - Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Stop listening {supportedPaymentMethod.GetLightningUrl().BaseUri}"); } - private async Task AddPayment(BTCPayNetwork network, LightningInvoice notification, ListenedInvoice listenedInvoice) + bool _ErrorAlreadyLogged = false; + ConcurrentDictionary _ListenedInvoices = new ConcurrentDictionary(); + + public async Task AddPayment(LightningInvoice notification, string invoiceId) { - var payment = await _InvoiceRepository.AddPayment(listenedInvoice.InvoiceId, notification.PaidAt.Value, new LightningLikePaymentData() + var payment = await invoiceRepository.AddPayment(invoiceId, notification.PaidAt.Value, new LightningLikePaymentData() { BOLT11 = notification.BOLT11, PaymentHash = BOLT11PaymentRequest.Parse(notification.BOLT11, network.NBitcoinNetwork).PaymentHash, @@ -208,102 +322,34 @@ namespace BTCPayServer.Payments.Lightning }, network, accounted: true); if (payment != null) { - var invoice = await _InvoiceRepository.GetInvoice(listenedInvoice.InvoiceId); + var invoice = await invoiceRepository.GetInvoice(invoiceId); if (invoice != null) - _Aggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment){Payment = payment}); + _eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) { Payment = payment }); } + return payment != null; } - List _ListeningLightning = new List(); - MultiValueDictionary _ListenedInvoiceByLightningUrl = new MultiValueDictionary(); - Dictionary _ListenedInvoiceByChargeInvoiceId = new Dictionary(); - HashSet _InvoiceIds = new HashSet(); - private Timer _ListenPoller; - - /// - /// Stop listening an invoice - /// - /// The invoice to stop listening - /// true if still need to listen the lightning instance - bool DoneListening(ListenedInvoice listenedInvoice) + internal void RemoveExpiredInvoices() { - lock (_ListenedInvoiceByLightningUrl) + foreach (var invoice in _ListenedInvoices) { - _ListenedInvoiceByChargeInvoiceId.Remove(listenedInvoice.PaymentMethodDetails.InvoiceId); - _ListenedInvoiceByLightningUrl.Remove(listenedInvoice.Uri, listenedInvoice); - _InvoiceIds.Remove(listenedInvoice.InvoiceId); - if (!_ListenedInvoiceByLightningUrl.ContainsKey(listenedInvoice.Uri)) - { - return true; - } + if (invoice.Value.IsExpired()) + _ListenedInvoices.TryRemove(invoice.Key, out var _); } - return false; - } - - /// - /// Stop listening all invoices on this server - /// - /// - private void DoneListening(LightningConnectionString connectionString) - { - var uri = connectionString.BaseUri; - lock (_ListenedInvoiceByChargeInvoiceId) - { - foreach (var listenedInvoice in _ListenedInvoiceByLightningUrl[uri.AbsoluteUri]) - { - _ListenedInvoiceByChargeInvoiceId.Remove(listenedInvoice.PaymentMethodDetails.InvoiceId); - _InvoiceIds.Remove(listenedInvoice.InvoiceId); - } - _ListenedInvoiceByLightningUrl.Remove(uri.AbsoluteUri); - } - } - - bool Listening(string invoiceId) - { - lock (_ListenedInvoiceByLightningUrl) - { - return _InvoiceIds.Contains(invoiceId); - } - } - - private ListenedInvoice GetListenedInvoice(string chargeInvoiceId) - { - ListenedInvoice listenedInvoice = null; - lock (_ListenedInvoiceByLightningUrl) - { - _ListenedInvoiceByChargeInvoiceId.TryGetValue(chargeInvoiceId, out listenedInvoice); - } - return listenedInvoice; - } - - bool StartListening(ListenedInvoice listenedInvoice) - { - lock (_ListenedInvoiceByLightningUrl) - { - if (_InvoiceIds.Contains(listenedInvoice.InvoiceId)) - return false; - if (!_ListenedInvoiceByLightningUrl.ContainsKey(listenedInvoice.Uri)) - { - var listen = Listen(listenedInvoice.SupportedPaymentMethod, listenedInvoice.Network); - _ListeningLightning.Add(listen); - } - _ListenedInvoiceByLightningUrl.Add(listenedInvoice.Uri, listenedInvoice); - _ListenedInvoiceByChargeInvoiceId.Add(listenedInvoice.PaymentMethodDetails.InvoiceId, listenedInvoice); - _InvoiceIds.Add(listenedInvoice.InvoiceId); - } - return true; - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - leases.Dispose(); - _Cts.Cancel(); - Task[] listening = null; - lock (_ListenedInvoiceByLightningUrl) - { - listening = _ListeningLightning.ToArray(); - } - await Task.WhenAll(listening); + if (_ListenedInvoices.IsEmpty) + StopListeningCancellationTokenSource?.Cancel(); } } + + class ListenedInvoice + { + public bool IsExpired() { return DateTimeOffset.UtcNow > Expiration; } + public DateTimeOffset Expiration { get; set; } + public LightningLikePaymentMethodDetails PaymentMethodDetails { get; set; } + public LightningSupportedPaymentMethod SupportedPaymentMethod { get; set; } + public PaymentMethod PaymentMethod { get; set; } + public string Uri { get; internal set; } + public BTCPayNetwork Network { get; internal set; } + public string InvoiceId { get; internal set; } + } }