diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/InvestmentStatus.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/InvestmentStatus.cs index 0d7e2711b..b62cbcb14 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/InvestmentStatus.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/InvestmentStatus.cs @@ -6,5 +6,6 @@ public enum InvestmentStatus Invalid = 0, PendingFounderSignatures, FounderSignaturesReceived, - Invested + Invested, + Cancelled } \ No newline at end of file diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/GetProjectInvestments.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/GetProjectInvestments.cs index 1e6d9db8d..71441242b 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/GetProjectInvestments.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/GetProjectInvestments.cs @@ -83,6 +83,14 @@ private InvestmentStatus DetermineInvestmentStatus( InvestmentHandshake Handshake, List alreadyInvested) { + // Direct investments (below threshold) are already invested - no approval needed + if (Handshake.IsDirectInvestment) + return InvestmentStatus.Invested; + + // Check if cancelled + if (Handshake.Status == InvestmentRequestStatus.Cancelled) + return InvestmentStatus.Cancelled; + if (string.IsNullOrEmpty(Handshake.InvestmentTransactionHex)) return InvestmentStatus.PendingFounderSignatures; @@ -108,6 +116,22 @@ private Investment CreateInvestmentFromHandshake( List alreadyInvested, Project project) { + // Handle direct investments (below threshold) - they only have transaction ID, not full hex + if (Handshake.IsDirectInvestment) + { + var transactionId = Handshake.InvestmentTransactionId ?? string.Empty; + var indexedInvestment = alreadyInvested.FirstOrDefault(i => i.TransactionId == transactionId); + var amount = indexedInvestment?.TotalAmount ?? 0; + + return new Investment( + Handshake.RequestEventId, + Handshake.RequestCreated, + transactionId, // Use transaction ID instead of hex + Handshake.InvestorNostrPubKey, + amount, + InvestmentStatus.Invested); + } + if (string.IsNullOrEmpty(Handshake.InvestmentTransactionHex)) { // Invalid investment - missing transaction hex @@ -121,7 +145,7 @@ private Investment CreateInvestmentFromHandshake( } var transaction = networkConfiguration.GetNetwork().CreateTransaction(Handshake.InvestmentTransactionHex); - var amount = transaction.GetTotalInvestmentAmount(); + var amount2 = transaction.GetTotalInvestmentAmount(); var investmentStatus = DetermineInvestmentStatus(Handshake, alreadyInvested); @@ -130,7 +154,7 @@ private Investment CreateInvestmentFromHandshake( Handshake.RequestCreated, Handshake.InvestmentTransactionHex, Handshake.InvestorNostrPubKey, - amount, + amount2, investmentStatus); } diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/InvestmentAppService.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/InvestmentAppService.cs index 73bd11945..9f78943e9 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/InvestmentAppService.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/InvestmentAppService.cs @@ -1,9 +1,4 @@ -using Angor.Sdk.Common; -using Angor.Sdk.Funding.Investor.Dtos; using Angor.Sdk.Funding.Investor.Operations; -using Angor.Sdk.Funding.Projects.Domain; -using Angor.Sdk.Funding.Shared; -using Angor.Sdk.Funding.Shared.TransactionDrafts; using CSharpFunctionalExtensions; using MediatR; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/CancelInvestmentRequest.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/CancelInvestmentRequest.cs index 20a752399..00313d71d 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/CancelInvestmentRequest.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/CancelInvestmentRequest.cs @@ -27,7 +27,8 @@ public record CancelInvestmentRequestResponse(); public class CancelInvestmentRequestHandler( IPortfolioService portfolioService, INetworkConfiguration networkConfiguration, - IWalletAccountBalanceService walletAccountBalanceService) : IRequestHandler> + IWalletAccountBalanceService walletAccountBalanceService, + IMediator mediator) : IRequestHandler> { public async Task> Handle(CancelInvestmentRequestRequest request, CancellationToken cancellationToken) { @@ -45,6 +46,15 @@ public async Task> Handle(CancelInvestme await ReleaseReservedUtxos(request.WalletId, record.InvestmentTransactionHex); } + // Notify founder of cancellation if there was a signature request + if (!string.IsNullOrEmpty(record.RequestEventId)) + { + await mediator.Send(new NotifyFounderOfCancellation.NotifyFounderOfCancellationRequest( + request.WalletId, + request.ProjectId, + record.RequestEventId), cancellationToken); + } + var res = await portfolioService.RemoveInvestmentRecordAsync(request.WalletId.Value, record); if (res.IsFailure) diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/InvestmentNotification.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/InvestmentNotification.cs new file mode 100644 index 000000000..90f074bfa --- /dev/null +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/InvestmentNotification.cs @@ -0,0 +1,36 @@ +namespace Angor.Sdk.Funding.Investor.Operations; + +/// +/// Notification sent to founder when a below-threshold investment is published directly to the blockchain. +/// Contains only the essential information needed - the founder can fetch full details from the indexer. +/// +public class InvestmentNotification +{ + /// + /// The project identifier this investment is for. + /// + public string ProjectIdentifier { get; set; } = string.Empty; + + /// + /// The transaction ID of the published investment transaction. + /// + public string TransactionId { get; set; } = string.Empty; +} + +/// +/// Notification sent to founder when an investor cancels their investment request. +/// This is sent unencrypted as it only contains public identifiers. +/// +public class CancellationNotification +{ + /// + /// The project identifier this cancellation is for. + /// + public string ProjectIdentifier { get; set; } = string.Empty; + + /// + /// The Nostr event ID of the original investment request that is being cancelled. + /// + public string RequestEventId { get; set; } = string.Empty; +} + diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/NotifyFounderOfCancellation.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/NotifyFounderOfCancellation.cs new file mode 100644 index 000000000..b33763eac --- /dev/null +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/NotifyFounderOfCancellation.cs @@ -0,0 +1,73 @@ +using Angor.Sdk.Common; +using Angor.Sdk.Funding.Services; +using Angor.Sdk.Funding.Shared; +using Angor.Shared; +using Angor.Shared.Services; +using Blockcore.NBitcoin; +using Blockcore.NBitcoin.DataEncoders; +using CSharpFunctionalExtensions; +using MediatR; + +namespace Angor.Sdk.Funding.Investor.Operations; + +public static class NotifyFounderOfCancellation +{ + public record NotifyFounderOfCancellationRequest( + WalletId WalletId, + ProjectId ProjectId, + string RequestEventId) : IRequest>; + + public record NotifyFounderOfCancellationResponse(DateTime EventTime, string EventId); + + public class NotifyFounderOfCancellationHandler( + IProjectService projectService, + ISeedwordsProvider seedwordsProvider, + IDerivationOperations derivationOperations, + ISerializer serializer, + ISignService signService) : IRequestHandler> + { + public async Task> Handle( + NotifyFounderOfCancellationRequest request, + CancellationToken cancellationToken) + { + var projectResult = await projectService.GetAsync(request.ProjectId); + if (projectResult.IsFailure) + return Result.Failure(projectResult.Error); + + var sensitiveDataResult = await seedwordsProvider.GetSensitiveData(request.WalletId.Value); + if (sensitiveDataResult.IsFailure) + return Result.Failure(sensitiveDataResult.Error); + + var walletWords = sensitiveDataResult.Value.ToWalletWords(); + var project = projectResult.Value; + + try + { + var investorNostrPrivateKey = await derivationOperations.DeriveProjectNostrPrivateKeyAsync( + walletWords, project.FounderKey); + var investorNostrPrivateKeyHex = Encoders.Hex.EncodeData(investorNostrPrivateKey.ToBytes()); + + var notification = new CancellationNotification + { + ProjectIdentifier = request.ProjectId.Value, + RequestEventId = request.RequestEventId + }; + + var content = serializer.Serialize(notification); + + var (eventTime, eventId) = signService.NotifyInvestmentCancelled( + content, + investorNostrPrivateKeyHex, + project.NostrPubKey, + _ => { }); + + return Result.Success(new NotifyFounderOfCancellationResponse(eventTime, eventId)); + } + catch (Exception ex) + { + return Result.Failure($"Error sending cancellation notification: {ex.Message}"); + } + } + } +} + diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/NotifyFounderOfInvestment.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/NotifyFounderOfInvestment.cs new file mode 100644 index 000000000..6e6137f23 --- /dev/null +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/NotifyFounderOfInvestment.cs @@ -0,0 +1,113 @@ +using Angor.Sdk.Common; +using Angor.Sdk.Funding.Projects.Domain; +using Angor.Sdk.Funding.Services; +using Angor.Sdk.Funding.Shared; +using Angor.Sdk.Funding.Shared.TransactionDrafts; +using Angor.Shared; +using Angor.Shared.Models; +using Angor.Shared.Services; +using Blockcore.NBitcoin; +using Blockcore.NBitcoin.DataEncoders; +using CSharpFunctionalExtensions; +using MediatR; + +namespace Angor.Sdk.Funding.Investor.Operations; + +/// +/// Notifies the founder of a below-threshold investment that was published directly to the blockchain. +/// This allows the investment to appear in the founder's investment list with the investor's Nostr pubkey for chat functionality. +/// +public static class NotifyFounderOfInvestment +{ + public record NotifyFounderOfInvestmentRequest( + WalletId WalletId, + ProjectId ProjectId, + InvestmentDraft Draft) : IRequest>; + + public record NotifyFounderOfInvestmentResponse(DateTime EventTime, string EventId); + + public class NotifyFounderOfInvestmentHandler( + IProjectService projectService, + ISeedwordsProvider seedwordsProvider, + IDerivationOperations derivationOperations, + IEncryptionService encryptionService, + ISerializer serializer, + ISignService signService) : IRequestHandler> + { + public async Task> Handle( + NotifyFounderOfInvestmentRequest request, + CancellationToken cancellationToken) + { + var projectResult = await projectService.GetAsync(request.ProjectId); + if (projectResult.IsFailure) + { + return Result.Failure(projectResult.Error); + } + + var sensitiveDataResult = await seedwordsProvider.GetSensitiveData(request.WalletId.Value); + if (sensitiveDataResult.IsFailure) + { + return Result.Failure(sensitiveDataResult.Error); + } + + var walletWords = sensitiveDataResult.Value.ToWalletWords(); + var project = projectResult.Value; + + var notificationResult = await SendInvestmentNotification( + walletWords, + project, + request.ProjectId.Value, + request.Draft.TransactionId); + + if (notificationResult.IsFailure) + { + return Result.Failure(notificationResult.Error); + } + + return Result.Success(new NotifyFounderOfInvestmentResponse( + notificationResult.Value.eventTime, + notificationResult.Value.eventId)); + } + + private async Task> SendInvestmentNotification( + WalletWords walletWords, + Project project, + string projectIdentifier, + string transactionId) + { + try + { + var investorNostrPrivateKey = await derivationOperations.DeriveProjectNostrPrivateKeyAsync( + walletWords, + project.FounderKey); + var investorNostrPrivateKeyHex = Encoders.Hex.EncodeData(investorNostrPrivateKey.ToBytes()); + + var notification = new InvestmentNotification + { + ProjectIdentifier = projectIdentifier, + TransactionId = transactionId + }; + + var serializedNotification = serializer.Serialize(notification); + + var encryptedContent = await encryptionService.EncryptNostrContentAsync( + investorNostrPrivateKeyHex, + project.NostrPubKey, + serializedNotification); + + var (eventTime, eventId) = signService.NotifyInvestmentCompleted( + encryptedContent, + investorNostrPrivateKeyHex, + project.NostrPubKey, + _ => { }); + + return Result.Success((eventTime, eventId)); + } + catch (Exception ex) + { + return Result.Failure<(DateTime, string)>($"Error sending investment notification: {ex.Message}"); + } + } + } +} + diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/PublishAndStoreInvestorTransaction.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/PublishAndStoreInvestorTransaction.cs index 2f3e293b9..89eb42ace 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/PublishAndStoreInvestorTransaction.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/PublishAndStoreInvestorTransaction.cs @@ -1,3 +1,4 @@ +using Angor.Sdk.Common; using Angor.Sdk.Funding.Investor.Domain; using Angor.Sdk.Funding.Shared; using Angor.Sdk.Funding.Shared.TransactionDrafts; @@ -13,7 +14,7 @@ public record PublishAndStoreInvestorTransactionRequest(string? WalletId, Shared public record PublishAndStoreInvestorTransactionResponse(string TransactionId); - public class Handler(IIndexerService indexerService, IPortfolioService portfolioService) : IRequestHandler> + public class Handler(IIndexerService indexerService, IPortfolioService portfolioService, IMediator mediator) : IRequestHandler> { public async Task> Handle(PublishAndStoreInvestorTransactionRequest request, CancellationToken cancellationToken) { @@ -45,6 +46,21 @@ public async Task> Handle(Pub if (updateResult.IsFailure) return Result.Failure(updateResult.Error); + // For investment drafts, notify the founder via Nostr so the investment appears in their list + if (request.TransactionDraft is InvestmentDraft investmentDraft) + { + var notifyResult = await mediator.Send(new NotifyFounderOfInvestment.NotifyFounderOfInvestmentRequest( + new WalletId(request.WalletId), + request.ProjectId, + investmentDraft), cancellationToken); + + // Log but don't fail if notification fails - the transaction is already published + if (notifyResult.IsFailure) + { + // TODO: Consider logging this failure + } + } + return Result.Success(new PublishAndStoreInvestorTransactionResponse(request.TransactionDraft.TransactionId)); } diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Services/DocumentProjectService.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Services/DocumentProjectService.cs index 8c2e3d2d8..80b5bc6c1 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Services/DocumentProjectService.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Services/DocumentProjectService.cs @@ -209,27 +209,21 @@ void OnNext(EventInfo eventInfo) return Uri.TryCreate(uriString, UriKind.Absolute, out var uri) ? uri : null; } - private Task>> ProjectInfos(IEnumerable eventIds) + private async Task>> ProjectInfos(IEnumerable eventIds) { - return Task.Run(async () => - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var tcs = new TaskCompletionSource>(); - var results = new List(); - - void OnNext(ProjectInfo info) => results.Add(info); - void OnCompleted() => tcs.SetResult(results); + var tcs = new TaskCompletionSource>(); + var results = new List(); - relayService.LookupProjectsInfoByEventIds(OnNext, OnCompleted, eventIds.ToArray()); + void OnNext(ProjectInfo info) => results.Add(info); + void OnCompleted() => tcs.TrySetResult(results); - // Race between completion and timeout - var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(10000, cts.Token)); + relayService.LookupProjectsInfoByEventIds(OnNext, OnCompleted, eventIds.ToArray()); - if (completedTask == tcs.Task) - return Result.Success(results.AsEnumerable()); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + cts.Token.Register(() => tcs.TrySetResult(results)); - return Result.Failure>("Timeout waiting for project info"); - }); + var completedResults = await tcs.Task; + return Result.Success(completedResults.AsEnumerable()); } private Task>> ProjectMetadatas(IEnumerable npubs) diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Services/InvestmentHandshakeService.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Services/InvestmentHandshakeService.cs index 85fdb8669..64a787db2 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Services/InvestmentHandshakeService.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Services/InvestmentHandshakeService.cs @@ -1,4 +1,5 @@ using Angor.Sdk.Common; +using Angor.Sdk.Funding.Investor.Operations; using Angor.Sdk.Funding.Shared; using Angor.Data.Documents.Interfaces; using Angor.Shared; @@ -99,29 +100,39 @@ public async Task>> SyncHandshakesFromNo { logger.Information("Starting sync of investment Handshakes for project {ProjectId}", projectId.Value); - // Fetch all investment requests from Nostr - var requestsResult = await FetchInvestmentRequestsFromNostr(projectNostrPubKey); - if (requestsResult.IsFailure) + // Fetch all investment-related messages in a single call + var messagesResult = await FetchAllInvestmentMessagesFromNostr(projectNostrPubKey); + if (messagesResult.IsFailure) { - logger.Error("Failed to fetch investment requests: {Error}", requestsResult.Error); - return Result.Failure>(requestsResult.Error); + logger.Error("Failed to fetch investment messages: {Error}", messagesResult.Error); + return Result.Failure>(messagesResult.Error); } - var requests = requestsResult.Value.ToList(); - logger.Information("Fetched {Count} investment requests", requests.Count); + var (requests, notifications, cancellations, approvals) = messagesResult.Value; + + logger.Information("Fetched {RequestCount} requests, {NotificationCount} notifications, {CancellationCount} cancellations, {ApprovalCount} approvals", + requests.Count, notifications.Count, cancellations.Count, approvals.Count); - // Fetch all approvals from Nostr - var approvalsResult = await FetchInvestmentApprovalsFromNostr(projectNostrPubKey); - if (approvalsResult.IsFailure) + // Parse cancellations into a lookup dictionary by RequestEventId + var cancellationLookup = new Dictionary(); + foreach (var cancellation in cancellations) { - logger.Warning("Failed to fetch approvals: {Error}", approvalsResult.Error); + try + { + var cancelNotification = serializer.Deserialize(cancellation.Content); + if (cancelNotification != null && !string.IsNullOrEmpty(cancelNotification.RequestEventId)) + { + cancellationLookup[cancelNotification.RequestEventId] = cancelNotification; + } + } + catch (Exception ex) + { + logger.Warning(ex, "Failed to parse cancellation {EventId}", cancellation.Id); + } } - var approvals = approvalsResult.IsSuccess - ? approvalsResult.Value.ToDictionary(a => a.EventIdentifier, a => a) - : new Dictionary(); - - logger.Information("Fetched {Count} investment approvals", approvals.Count); + // Build approvals lookup by event identifier + var approvalsLookup = approvals.ToDictionary(a => a.EventIdentifier, a => a); // Get existing Handshakes from database var existingResult = await _collection.FindAsync(c => @@ -140,17 +151,35 @@ public async Task>> SyncHandshakesFromNo // Check if we already have this Handshake if (existingHandshakes.TryGetValue(request.Id, out var existingHandshake)) { - // Update existing Handshake if approval status changed - if (approvals.TryGetValue(request.Id, out var approval)) + var needsUpdate = false; + + // Always check and update approval fields if available + if (approvalsLookup.TryGetValue(request.Id, out var approval)) { if (existingHandshake.ApprovalEventId != approval.EventIdentifier) { existingHandshake.ApprovalEventId = approval.EventIdentifier; existingHandshake.ApprovalCreated = approval.Created; - existingHandshake.Status = InvestmentRequestStatus.Approved; - HandshakesToUpsert.Add(existingHandshake); + if (existingHandshake.Status == InvestmentRequestStatus.Pending) + { + existingHandshake.Status = InvestmentRequestStatus.Approved; + } + needsUpdate = true; } } + + // Apply cancellation status last (takes precedence over approval) + if (cancellationLookup.ContainsKey(request.Id) && existingHandshake.Status != InvestmentRequestStatus.Cancelled) + { + existingHandshake.Status = InvestmentRequestStatus.Cancelled; + needsUpdate = true; + } + + if (needsUpdate) + { + existingHandshake.UpdatedAt = DateTime.UtcNow; + HandshakesToUpsert.Add(existingHandshake); + } continue; } @@ -170,7 +199,17 @@ public async Task>> SyncHandshakesFromNo } // Check if this request has an approval - var hasApproval = approvals.TryGetValue(request.Id, out var requestApproval); + var hasApproval = approvalsLookup.TryGetValue(request.Id, out var requestApproval); + + // Check if this request has been cancelled + var isCancelled = cancellationLookup.ContainsKey(request.Id); + + // Determine the status: cancelled takes precedence, then approved, then pending + var status = isCancelled + ? InvestmentRequestStatus.Cancelled + : hasApproval + ? InvestmentRequestStatus.Approved + : InvestmentRequestStatus.Pending; // Create Handshake object var Handshake = new InvestmentHandshake @@ -187,7 +226,7 @@ public async Task>> SyncHandshakesFromNo UnfundedReleaseKey = recoveryRequest?.UnfundedReleaseKey, ApprovalEventId = hasApproval ? requestApproval.EventIdentifier : null, ApprovalCreated = hasApproval ? requestApproval.Created : null, - Status = hasApproval ? InvestmentRequestStatus.Approved : InvestmentRequestStatus.Pending, + Status = status, IsSynced = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow @@ -196,6 +235,57 @@ public async Task>> SyncHandshakesFromNo HandshakesToUpsert.Add(Handshake); } + // Process each notification (direct investments below threshold) + foreach (var notification in notifications) + { + // Check if we already have this Handshake + if (existingHandshakes.TryGetValue(notification.Id, out _)) + { + continue; + } + + InvestmentNotification? investmentNotification = null; + + try + { + var decryptResult = await nostrDecrypter.Decrypt(walletId, projectId, notification); + if (decryptResult.IsSuccess) + { + investmentNotification = serializer.Deserialize(decryptResult.Value); + } + } + catch (Exception ex) + { + logger.Warning(ex, "Failed to decrypt/parse notification {EventId}", notification.Id); + } + + // Create Handshake object for direct investment + var Handshake = new InvestmentHandshake + { + Id = GenerateCompositeId(walletId, projectId, notification.Id), + WalletId = walletId.Value, + ProjectId = projectId.Value, + RequestEventId = notification.Id, + InvestorNostrPubKey = notification.SenderNostrPubKey, + RequestCreated = notification.Created, + ProjectIdentifier = investmentNotification?.ProjectIdentifier, + InvestmentTransactionId = investmentNotification?.TransactionId, + InvestmentTransactionHex = null, // Not sent in notification, can be fetched from indexer if needed + UnfundedReleaseAddress = null, // Not applicable for direct investments + UnfundedReleaseKey = null, + ApprovalEventId = null, // Direct investments don't need approval + ApprovalCreated = null, + Status = InvestmentRequestStatus.Invested, // Already invested + IsSynced = true, + IsDirectInvestment = true, // Mark as direct investment + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + HandshakesToUpsert.Add(Handshake); + } + + // Store all Handshakes if (HandshakesToUpsert.Any()) { @@ -236,52 +326,53 @@ private static string GenerateCompositeId(WalletId walletId, ProjectId projectId return Convert.ToHexString(hashBytes); } - private async Task>> FetchInvestmentRequestsFromNostr(string projectNostrPubKey) + private async Task Requests, List Notifications, List Cancellations, List Approvals)>> + FetchAllInvestmentMessagesFromNostr(string projectNostrPubKey) { try { - var tcs = new TaskCompletionSource>(); - var messages = new List(); + var tcs = new TaskCompletionSource(); + + var requests = new List(); + var notifications = new List(); + var cancellations = new List(); + var approvals = new List(); - await signService.LookupInvestmentRequestsAsync( + await signService.LookupAllInvestmentMessagesAsync( projectNostrPubKey, null, null, - (id, pubKey, content, created) => messages.Add(new DirectMessage(id, pubKey, content, created)), - () => tcs.SetResult(messages) - ); - - var result = await tcs.Task; - return Result.Success>(result); - } - catch (Exception ex) - { - logger.Error(ex, "Failed to fetch investment requests from Nostr"); - return Result.Failure>($"Failed to fetch investment requests: {ex.Message}"); - } - } - - private async Task>> FetchInvestmentApprovalsFromNostr(string projectNostrPubKey) - { - try - { - var tcs = new TaskCompletionSource>(); - var approvals = new List(); - - signService.LookupInvestmentRequestApprovals( - projectNostrPubKey, - (profileIdentifier, created, eventIdentifier) => - approvals.Add(new ApprovalInfo(profileIdentifier, created, eventIdentifier)), - () => tcs.SetResult(approvals) + (messageType, id, pubKey, content, created) => + { + switch (messageType) + { + case InvestmentMessageType.Request: + requests.Add(new DirectMessage(id, pubKey, content, created)); + break; + case InvestmentMessageType.Notification: + notifications.Add(new DirectMessage(id, pubKey, content, created)); + break; + case InvestmentMessageType.Cancellation: + cancellations.Add(new DirectMessage(id, pubKey, content, created)); + break; + case InvestmentMessageType.Approval: + // For approvals, we need to extract the event identifier from the content/tags + // The pubKey here is the founder's pubKey, and we need the referenced event ID + approvals.Add(new ApprovalInfo(pubKey, created, id)); + break; + } + }, + () => tcs.SetResult(true) ); - var result = await tcs.Task; - return Result.Success>(result); + await tcs.Task; + return Result.Success((requests, notifications, cancellations, approvals)); } catch (Exception ex) { - logger.Error(ex, "Failed to fetch investment approvals from Nostr"); - return Result.Failure>($"Failed to fetch approvals: {ex.Message}"); + logger.Error(ex, "Failed to fetch investment messages from Nostr"); + return Result.Failure<(List, List, List, List)>( + $"Failed to fetch investment messages: {ex.Message}"); } } diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Shared/InvestmentHandshake.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Shared/InvestmentHandshake.cs index 0b4cc046f..63fe81abb 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Shared/InvestmentHandshake.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Shared/InvestmentHandshake.cs @@ -35,6 +35,13 @@ public class InvestmentHandshake : BaseDocument // SignRecoveryRequest properties public string? ProjectIdentifier { get; set; } public string? InvestmentTransactionHex { get; set; } + + /// + /// Transaction ID for direct investments (where only the ID is sent, not the full hex). + /// The founder can fetch the full transaction from the indexer using this ID. + /// + public string? InvestmentTransactionId { get; set; } + public string? UnfundedReleaseAddress { get; set; } public string? UnfundedReleaseKey { get; set; } @@ -58,4 +65,10 @@ public class InvestmentHandshake : BaseDocument /// Whether this has been synced from Nostr /// public bool IsSynced { get; set; } + + /// + /// Whether this is a direct investment (below threshold, no founder approval required). + /// These investments are notified via "Investment completed" subject rather than "Investment offer". + /// + public bool IsDirectInvestment { get; set; } } \ No newline at end of file diff --git a/src/Angor/Shared/Services/ISignService.cs b/src/Angor/Shared/Services/ISignService.cs index 2186954fc..06b79d98d 100644 --- a/src/Angor/Shared/Services/ISignService.cs +++ b/src/Angor/Shared/Services/ISignService.cs @@ -3,15 +3,66 @@ namespace Angor.Shared.Services; +/// +/// Types of investment-related messages that can be received via Nostr. +/// +public enum InvestmentMessageType +{ + /// Investment signature request ("Investment offer") + Request, + /// Investment completion notification ("Investment completed") + Notification, + /// Investment cancellation notification ("Investment cancelled") + Cancellation, + /// Founder approval response ("Re:Investment offer") + Approval +} + public interface ISignService { (DateTime eventTime, string eventId) RequestInvestmentSigs(string encryptedContent, string investorNostrPrivateKey, string founderNostrPubKey, Action okResponse); + + /// + /// Sends a notification to the founder that an investment has been completed (for below-threshold investments that don't require signatures). + /// + (DateTime eventTime, string eventId) NotifyInvestmentCompleted(string encryptedContent, string investorNostrPrivateKey, + string founderNostrPubKey, Action okResponse); + void LookupSignatureForInvestmentRequest(string investorNostrPubKey, string projectNostrPubKey, DateTime? sigRequestSentTime, string sigRequestEventId, Func action,Action? onAllMessagesReceived = null); Task LookupInvestmentRequestsAsync(string nostrPubKey, string? senderNpub, DateTime? since, Action action, Action onAllMessagesReceived); + /// + /// Looks up investment completion notifications (for below-threshold investments that don't require signatures). + /// + Task LookupInvestmentNotificationsAsync(string nostrPubKey, string? senderNpub, DateTime? since, Action action, + Action onAllMessagesReceived); + + /// + /// Sends an unencrypted notification to the founder that an investment request has been cancelled. + /// + (DateTime eventTime, string eventId) NotifyInvestmentCancelled(string content, string investorNostrPrivateKey, + string founderNostrPubKey, Action okResponse); + + /// + /// Looks up investment cancellation notifications. + /// + Task LookupInvestmentCancellationsAsync(string nostrPubKey, string? senderNpub, DateTime? since, + Action action, Action onAllMessagesReceived); + + /// + /// Looks up all investment-related messages for a project and categorizes them by type. + /// Returns requests, notifications, cancellations, and approvals in a single call. + /// + Task LookupAllInvestmentMessagesAsync( + string nostrPubKey, + string? senderNpub, + DateTime? since, + Action onMessage, + Action onAllMessagesReceived); + void LookupInvestmentRequestApprovals(string nostrPubKey, Action action, Action onAllMessagesReceived); diff --git a/src/Angor/Shared/Services/RelaySubscriptionsHandling.cs b/src/Angor/Shared/Services/RelaySubscriptionsHandling.cs index 6d937539d..e32d3f3b7 100644 --- a/src/Angor/Shared/Services/RelaySubscriptionsHandling.cs +++ b/src/Angor/Shared/Services/RelaySubscriptionsHandling.cs @@ -97,20 +97,18 @@ public void HandleEoseMessages(NostrEoseResponse _) if (!_communicationFactory.EoseEventReceivedOnAllRelays(_.Subscription)) return; - if (userEoseActions.TryGetValue(_.Subscription, out var action)) + if (userEoseActions.Remove(_.Subscription, out var action)) { - _logger.LogDebug($"Invoking action on EOSE - {_.Subscription}"); + _logger.LogDebug($"Removed action on EOSE for subscription - {_.Subscription}"); try { + _logger.LogDebug($"Invoking action on EOSE - {_.Subscription}"); action.Invoke(); } catch (Exception e) { _logger.LogError(e, "Failed to invoke end of events event action"); } - - userEoseActions.Remove(_.Subscription, out var _); - _logger.LogDebug($"Removed action on EOSE for subscription - {_.Subscription}"); } _communicationFactory.ClearEoseReceivedOnSubscriptionMonitoring(_.Subscription); diff --git a/src/Angor/Shared/Services/SignService.cs b/src/Angor/Shared/Services/SignService.cs index 9dab44b02..49b074e44 100644 --- a/src/Angor/Shared/Services/SignService.cs +++ b/src/Angor/Shared/Services/SignService.cs @@ -1,4 +1,4 @@ -using System.Reactive.Linq; +using System.Reactive.Linq; using Angor.Shared.Models; using Newtonsoft.Json; using Nostr.Client.Keys; @@ -50,6 +50,31 @@ public SignService(INostrCommunicationFactory communicationFactory, INetworkServ return (signed.CreatedAt!.Value, signed.Id!); } + public (DateTime,string) NotifyInvestmentCompleted(string encryptedContent, string investorNostrPrivateKey, string founderNostrPubKey, Action okResponse) + { + var sender = NostrPrivateKey.FromHex(investorNostrPrivateKey); + + var ev = new NostrEvent + { + Kind = NostrKind.EncryptedDm, + CreatedAt = DateTime.UtcNow, + Content = encryptedContent, + Tags = new NostrEventTags( + NostrEventTag.Profile(founderNostrPubKey), + new NostrEventTag("subject","Investment completed")) + }; + + var signed = ev.Sign(sender); + + if(!_subscriptionsHanding.TryAddOKAction(signed.Id!,okResponse)) + throw new Exception("Failed to add OK action"); + + var nostrClient = _communicationFactory.GetOrCreateClient(_networkService); + nostrClient.Send(new NostrEventRequest(signed)); + + return (signed.CreatedAt!.Value, signed.Id!); + } + public void LookupSignatureForInvestmentRequest(string investorNostrPubKey, string projectNostrPubKey, DateTime? sigRequestSentTime, string sigRequestEventId, Func action, Action? onAllMessagesReceived = null) @@ -118,6 +143,103 @@ public Task LookupInvestmentRequestsAsync(string nostrPubKey, string? senderNpub return Task.CompletedTask; } + public Task LookupInvestmentNotificationsAsync(string nostrPubKey, string? senderNpub, DateTime? since, + Action action, Action onAllMessagesReceived) + { + var nostrClient = _communicationFactory.GetOrCreateClient(_networkService); + var subscriptionKey = nostrPubKey + "inv_notif"; + + if (!_subscriptionsHanding.RelaySubscriptionAdded(subscriptionKey)) + { + var subscription = nostrClient.Streams.EventStream + .Where(_ => _.Subscription == subscriptionKey) + .Where(_ => _.Event.Tags.FindFirstTagValue("subject") == "Investment completed") + .Select(_ => _.Event) + .Subscribe(nostrEvent => + { + action.Invoke(nostrEvent.Id, nostrEvent.Pubkey, nostrEvent.Content, nostrEvent.CreatedAt.Value); + }); + + _subscriptionsHanding.TryAddRelaySubscription(subscriptionKey, subscription); + } + + _subscriptionsHanding.TryAddEoseAction(subscriptionKey, onAllMessagesReceived); + + var nostrFilter = new NostrFilter + { + P = [nostrPubKey], //To founder, + Kinds = [NostrKind.EncryptedDm], + Since = since + }; + + if (senderNpub != null) nostrFilter.Authors = [senderNpub]; //From investor + + nostrClient.Send(new NostrRequest(subscriptionKey, nostrFilter)); + + return Task.CompletedTask; + } + + public (DateTime,string) NotifyInvestmentCancelled(string content, string investorNostrPrivateKey, string founderNostrPubKey, Action okResponse) + { + var sender = NostrPrivateKey.FromHex(investorNostrPrivateKey); + + var ev = new NostrEvent + { + Kind = NostrKind.EncryptedDm, + CreatedAt = DateTime.UtcNow, + Content = content, + Tags = new NostrEventTags( + NostrEventTag.Profile(founderNostrPubKey), + new NostrEventTag("subject","Investment cancelled")) + }; + + var signed = ev.Sign(sender); + + if(!_subscriptionsHanding.TryAddOKAction(signed.Id!,okResponse)) + throw new Exception("Failed to add OK action"); + + var nostrClient = _communicationFactory.GetOrCreateClient(_networkService); + nostrClient.Send(new NostrEventRequest(signed)); + + return (signed.CreatedAt!.Value, signed.Id!); + } + + public Task LookupInvestmentCancellationsAsync(string nostrPubKey, string? senderNpub, DateTime? since, + Action action, Action onAllMessagesReceived) + { + var nostrClient = _communicationFactory.GetOrCreateClient(_networkService); + var subscriptionKey = nostrPubKey + "inv_cancel"; + + if (!_subscriptionsHanding.RelaySubscriptionAdded(subscriptionKey)) + { + var subscription = nostrClient.Streams.EventStream + .Where(_ => _.Subscription == subscriptionKey) + .Where(_ => _.Event.Tags.FindFirstTagValue("subject") == "Investment cancelled") + .Select(_ => _.Event) + .Subscribe(nostrEvent => + { + action.Invoke(nostrEvent.Id, nostrEvent.Pubkey, nostrEvent.Content, nostrEvent.CreatedAt.Value); + }); + + _subscriptionsHanding.TryAddRelaySubscription(subscriptionKey, subscription); + } + + _subscriptionsHanding.TryAddEoseAction(subscriptionKey, onAllMessagesReceived); + + var nostrFilter = new NostrFilter + { + P = [nostrPubKey], + Kinds = [NostrKind.EncryptedDm], + Since = since + }; + + if (senderNpub != null) nostrFilter.Authors = [senderNpub]; + + nostrClient.Send(new NostrRequest(subscriptionKey, nostrFilter)); + + return Task.CompletedTask; + } + public void LookupInvestmentRequestApprovals(string nostrPubKey, Action action, Action onAllMessagesReceived) { var nostrClient = _communicationFactory.GetOrCreateClient(_networkService); @@ -146,6 +268,102 @@ public void LookupInvestmentRequestApprovals(string nostrPubKey, Action onMessage, + Action onAllMessagesReceived) + { + var nostrClient = _communicationFactory.GetOrCreateClient(_networkService); + var incomingKey = nostrPubKey.Substring(0, 20) + "_in"; + var outgoingKey = nostrPubKey.Substring(0, 20) + "_out"; + + var receivedEoseCount = 0; + + void CheckAllReceived() + { + if (Interlocked.Increment(ref receivedEoseCount) >= 2) + { + onAllMessagesReceived(); + } + } + + void HandleEvent(NostrEvent nostrEvent) + { + var subject = nostrEvent.Tags.FindFirstTagValue("subject"); + var messageType = subject switch + { + "Investment offer" => InvestmentMessageType.Request, + "Investment completed" => InvestmentMessageType.Notification, + "Investment cancelled" => InvestmentMessageType.Cancellation, + "Re:Investment offer" => InvestmentMessageType.Approval, + _ => (InvestmentMessageType?)null + }; + + if (messageType.HasValue) + { + // For approvals, we pass the referenced event ID (from e tag) as the id + // This allows matching the approval to the original request + var eventId = messageType.Value == InvestmentMessageType.Approval + ? nostrEvent.Tags.FindFirstTagValue(NostrEventTag.EventIdentifier) ?? nostrEvent.Id + : nostrEvent.Id; + + onMessage.Invoke( + messageType.Value, + eventId, + nostrEvent.Pubkey, + nostrEvent.Content, + nostrEvent.CreatedAt!.Value); + } + } + + // Incoming messages subscription (requests, notifications, cancellations TO founder) + if (!_subscriptionsHanding.RelaySubscriptionAdded(incomingKey)) + { + var incomingSub = nostrClient.Streams.EventStream + .Where(_ => _.Subscription == incomingKey) + .Select(_ => _.Event) + .Subscribe(HandleEvent); + + _subscriptionsHanding.TryAddRelaySubscription(incomingKey, incomingSub); + } + _subscriptionsHanding.TryAddEoseAction(incomingKey, CheckAllReceived); + + // Outgoing messages subscription (approvals FROM founder) + if (!_subscriptionsHanding.RelaySubscriptionAdded(outgoingKey)) + { + var outgoingSub = nostrClient.Streams.EventStream + .Where(_ => _.Subscription == outgoingKey) + .Select(_ => _.Event) + .Subscribe(HandleEvent); + + _subscriptionsHanding.TryAddRelaySubscription(outgoingKey, outgoingSub); + } + _subscriptionsHanding.TryAddEoseAction(outgoingKey, CheckAllReceived); + + // Fetch messages TO founder (requests, notifications, cancellations) + var incomingFilter = new NostrFilter + { + P = [nostrPubKey], + Kinds = [NostrKind.EncryptedDm], + Since = since + }; + if (senderNpub != null) incomingFilter.Authors = [senderNpub]; + nostrClient.Send(new NostrRequest(incomingKey, incomingFilter)); + + // Fetch messages FROM founder (approvals) + var outgoingFilter = new NostrFilter + { + Authors = [nostrPubKey], + Kinds = [NostrKind.EncryptedDm], + Since = since + }; + nostrClient.Send(new NostrRequest(outgoingKey, outgoingFilter)); + + return Task.CompletedTask; + } + public DateTime SendSignaturesToInvestor(string encryptedSignatureInfo, string nostrPrivateKeyHex, string investorNostrPubKey, string eventId) { var nostrPrivateKey = NostrPrivateKey.FromHex(nostrPrivateKeyHex);