Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ public enum InvestmentStatus
Invalid = 0,
PendingFounderSignatures,
FounderSignaturesReceived,
Invested
Invested,
Cancelled
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ private InvestmentStatus DetermineInvestmentStatus(
InvestmentHandshake Handshake,
List<ProjectInvestment> 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;

Expand All @@ -108,6 +116,22 @@ private Investment CreateInvestmentFromHandshake(
List<ProjectInvestment> 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
Expand All @@ -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);

Expand All @@ -130,7 +154,7 @@ private Investment CreateInvestmentFromHandshake(
Handshake.RequestCreated,
Handshake.InvestmentTransactionHex,
Handshake.InvestorNostrPubKey,
amount,
amount2,
investmentStatus);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public record CancelInvestmentRequestResponse();
public class CancelInvestmentRequestHandler(
IPortfolioService portfolioService,
INetworkConfiguration networkConfiguration,
IWalletAccountBalanceService walletAccountBalanceService) : IRequestHandler<CancelInvestmentRequestRequest, Result<CancelInvestmentRequestResponse>>
IWalletAccountBalanceService walletAccountBalanceService,
IMediator mediator) : IRequestHandler<CancelInvestmentRequestRequest, Result<CancelInvestmentRequestResponse>>
{
public async Task<Result<CancelInvestmentRequestResponse>> Handle(CancelInvestmentRequestRequest request, CancellationToken cancellationToken)
{
Expand All @@ -45,6 +46,15 @@ public async Task<Result<CancelInvestmentRequestResponse>> 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace Angor.Sdk.Funding.Investor.Operations;

/// <summary>
/// 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.
/// </summary>
public class InvestmentNotification
{
/// <summary>
/// The project identifier this investment is for.
/// </summary>
public string ProjectIdentifier { get; set; } = string.Empty;

/// <summary>
/// The transaction ID of the published investment transaction.
/// </summary>
public string TransactionId { get; set; } = string.Empty;
}

/// <summary>
/// Notification sent to founder when an investor cancels their investment request.
/// This is sent unencrypted as it only contains public identifiers.
/// </summary>
public class CancellationNotification
{
/// <summary>
/// The project identifier this cancellation is for.
/// </summary>
public string ProjectIdentifier { get; set; } = string.Empty;

/// <summary>
/// The Nostr event ID of the original investment request that is being cancelled.
/// </summary>
public string RequestEventId { get; set; } = string.Empty;
}

Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we have a general notification message and inside several options? looks to me like here we will have a different message type per notification

{
public record NotifyFounderOfCancellationRequest(
WalletId WalletId,
ProjectId ProjectId,
string RequestEventId) : IRequest<Result<NotifyFounderOfCancellationResponse>>;

public record NotifyFounderOfCancellationResponse(DateTime EventTime, string EventId);

public class NotifyFounderOfCancellationHandler(
IProjectService projectService,
ISeedwordsProvider seedwordsProvider,
IDerivationOperations derivationOperations,
ISerializer serializer,
ISignService signService) : IRequestHandler<NotifyFounderOfCancellationRequest, Result<NotifyFounderOfCancellationResponse>>
{
public async Task<Result<NotifyFounderOfCancellationResponse>> Handle(
NotifyFounderOfCancellationRequest request,
CancellationToken cancellationToken)
{
var projectResult = await projectService.GetAsync(request.ProjectId);
if (projectResult.IsFailure)
return Result.Failure<NotifyFounderOfCancellationResponse>(projectResult.Error);

var sensitiveDataResult = await seedwordsProvider.GetSensitiveData(request.WalletId.Value);
if (sensitiveDataResult.IsFailure)
return Result.Failure<NotifyFounderOfCancellationResponse>(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<NotifyFounderOfCancellationResponse>($"Error sending cancellation notification: {ex.Message}");
}
}
}
}

Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public static class NotifyFounderOfInvestment
{
public record NotifyFounderOfInvestmentRequest(
WalletId WalletId,
ProjectId ProjectId,
InvestmentDraft Draft) : IRequest<Result<NotifyFounderOfInvestmentResponse>>;

public record NotifyFounderOfInvestmentResponse(DateTime EventTime, string EventId);

public class NotifyFounderOfInvestmentHandler(
IProjectService projectService,
ISeedwordsProvider seedwordsProvider,
IDerivationOperations derivationOperations,
IEncryptionService encryptionService,
ISerializer serializer,
ISignService signService) : IRequestHandler<NotifyFounderOfInvestmentRequest, Result<NotifyFounderOfInvestmentResponse>>
{
public async Task<Result<NotifyFounderOfInvestmentResponse>> Handle(
NotifyFounderOfInvestmentRequest request,
CancellationToken cancellationToken)
{
var projectResult = await projectService.GetAsync(request.ProjectId);
if (projectResult.IsFailure)
{
return Result.Failure<NotifyFounderOfInvestmentResponse>(projectResult.Error);
}

var sensitiveDataResult = await seedwordsProvider.GetSensitiveData(request.WalletId.Value);
if (sensitiveDataResult.IsFailure)
{
return Result.Failure<NotifyFounderOfInvestmentResponse>(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<NotifyFounderOfInvestmentResponse>(notificationResult.Error);
}

return Result.Success(new NotifyFounderOfInvestmentResponse(
notificationResult.Value.eventTime,
notificationResult.Value.eventId));
}

private async Task<Result<(DateTime eventTime, string eventId)>> 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}");
}
}
}
}

Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,7 +14,7 @@ public record PublishAndStoreInvestorTransactionRequest(string? WalletId, Shared

public record PublishAndStoreInvestorTransactionResponse(string TransactionId);

public class Handler(IIndexerService indexerService, IPortfolioService portfolioService) : IRequestHandler<PublishAndStoreInvestorTransactionRequest, Result<PublishAndStoreInvestorTransactionResponse>>
public class Handler(IIndexerService indexerService, IPortfolioService portfolioService, IMediator mediator) : IRequestHandler<PublishAndStoreInvestorTransactionRequest, Result<PublishAndStoreInvestorTransactionResponse>>
{
public async Task<Result<PublishAndStoreInvestorTransactionResponse>> Handle(PublishAndStoreInvestorTransactionRequest request, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -45,6 +46,21 @@ public async Task<Result<PublishAndStoreInvestorTransactionResponse>> Handle(Pub
if (updateResult.IsFailure)
return Result.Failure<PublishAndStoreInvestorTransactionResponse>(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));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,27 +209,21 @@ void OnNext(EventInfo<ProjectInfo> eventInfo)
return Uri.TryCreate(uriString, UriKind.Absolute, out var uri) ? uri : null;
}

private Task<Result<IEnumerable<ProjectInfo>>> ProjectInfos(IEnumerable<string> eventIds)
private async Task<Result<IEnumerable<ProjectInfo>>> ProjectInfos(IEnumerable<string> eventIds)
{
return Task.Run(async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var tcs = new TaskCompletionSource<List<ProjectInfo>>();
var results = new List<ProjectInfo>();

void OnNext(ProjectInfo info) => results.Add(info);
void OnCompleted() => tcs.SetResult(results);
var tcs = new TaskCompletionSource<List<ProjectInfo>>();
var results = new List<ProjectInfo>();

relayService.LookupProjectsInfoByEventIds<ProjectInfo>(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<ProjectInfo>(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<IEnumerable<ProjectInfo>>("Timeout waiting for project info");
});
var completedResults = await tcs.Task;
return Result.Success(completedResults.AsEnumerable());
}

private Task<Result<IEnumerable<ProjectMetadataWithNpub>>> ProjectMetadatas(IEnumerable<string> npubs)
Expand Down
Loading
Loading