diff --git a/src/Angor.Test/ProtocolNew/InvestmentIntegrationsTests.cs b/src/Angor.Test/ProtocolNew/InvestmentIntegrationsTests.cs index e25f5f69..140d98a3 100644 --- a/src/Angor.Test/ProtocolNew/InvestmentIntegrationsTests.cs +++ b/src/Angor.Test/ProtocolNew/InvestmentIntegrationsTests.cs @@ -635,5 +635,83 @@ public void InvestorTransaction_NoPenalty_Test(int stageIndex) investorInvTrx.Outputs.AsCoins().Where(c => c.Amount > 0)); } } + + [Fact] + public void SpendInvestorReleaseTest() + { + var network = Networks.Bitcoin.Testnet(); + + var words = new WalletWords { Words = new Mnemonic(Wordlist.English, WordCount.Twelve).ToString() }; + + // Create the investor params + var investorKey = new Key(); + var investorChangeKey = new Key(); + + var funderKey = _derivationOperations.DeriveFounderKey(words, 1); + var angorKey = _derivationOperations.DeriveAngorKey(funderKey, angorRootKey); + var founderRecoveryKey = _derivationOperations.DeriveFounderRecoveryKey(words, 1); + var funderPrivateKey = _derivationOperations.DeriveFounderPrivateKey(words, 1); + var founderRecoveryPrivateKey = _derivationOperations.DeriveFounderRecoveryPrivateKey(words, 1); + + var investorContext = new InvestorContext + { + ProjectInfo = new ProjectInfo + { + TargetAmount = 3, + StartDate = DateTime.UtcNow, + ExpiryDate = DateTime.UtcNow.AddDays(5), + PenaltyDays = 5, + Stages = new List + { + new() { AmountToRelease = 1, ReleaseDate = DateTime.UtcNow.AddDays(1) }, + new() { AmountToRelease = 1, ReleaseDate = DateTime.UtcNow.AddDays(2) }, + new() { AmountToRelease = 1, ReleaseDate = DateTime.UtcNow.AddDays(3) } + }, + FounderKey = funderKey, + FounderRecoveryKey = founderRecoveryKey, + ProjectIdentifier = angorKey, + ProjectSeeders = new ProjectSeeders() + }, + InvestorKey = Encoders.Hex.EncodeData(investorKey.PubKey.ToBytes()), + ChangeAddress = investorChangeKey.PubKey.GetSegwitAddress(network).ToString() + }; + + var investorReleaseKey = new Key(); + var investorReleasePubKey = Encoders.Hex.EncodeData(investorReleaseKey.PubKey.ToBytes()); + + // Create the investment transaction + var investmentTransaction = _investorTransactionActions.CreateInvestmentTransaction(investorContext.ProjectInfo, investorContext.InvestorKey, + Money.Coins(investorContext.ProjectInfo.TargetAmount).Satoshi); + + investorContext.TransactionHex = investmentTransaction.ToHex(); + + // Build the release transaction + var releaseTransaction = _investorTransactionActions.BuildReleaseInvestorFundsTransaction(investorContext.ProjectInfo, investmentTransaction, investorReleasePubKey); + + // Sign the release transaction + var founderSignatures = _founderTransactionActions.SignInvestorRecoveryTransactions(investorContext.ProjectInfo, + investmentTransaction.ToHex(), releaseTransaction, + Encoders.Hex.EncodeData(founderRecoveryPrivateKey.ToBytes())); + + var signedReleaseTransaction = _investorTransactionActions.AddSignaturesToReleaseFundsTransaction(investorContext.ProjectInfo, + investmentTransaction, founderSignatures, Encoders.Hex.EncodeData(investorKey.ToBytes()), investorReleasePubKey); + + // Validate the signatures + var sigCheckResult = _investorTransactionActions.CheckInvestorReleaseSignatures(investorContext.ProjectInfo, investmentTransaction, founderSignatures, investorReleasePubKey); + Assert.True(sigCheckResult, "Failed to validate the founder's signatures"); + + List coins = new(); + foreach (var indexedTxOut in investmentTransaction.Outputs.AsIndexedOutputs().Where(w => !w.TxOut.ScriptPubKey.IsUnspendable)) + { + coins.Add(new Blockcore.NBitcoin.Coin(indexedTxOut)); + coins.Add(new Blockcore.NBitcoin.Coin(Blockcore.NBitcoin.uint256.Zero, 0, new Blockcore.NBitcoin.Money(1000), + new Script("4a8a3d6bb78a5ec5bf2c599eeb1ea522677c4b10132e554d78abecd7561e4b42"))); // Adding fee inputs + } + + signedReleaseTransaction.Inputs.Add(new Blockcore.Consensus.TransactionInfo.TxIn( + new Blockcore.Consensus.TransactionInfo.OutPoint(Blockcore.NBitcoin.uint256.Zero, 0), null)); // Add fee to the transaction + + TransactionValidation.ThanTheTransactionHasNoErrors(signedReleaseTransaction, coins); + } } } \ No newline at end of file diff --git a/src/Angor/Client/Models/FounderProject.cs b/src/Angor/Client/Models/FounderProject.cs index 0d4a09ab..36d00c44 100644 --- a/src/Angor/Client/Models/FounderProject.cs +++ b/src/Angor/Client/Models/FounderProject.cs @@ -9,7 +9,30 @@ public class FounderProject : Project public string ProjectInfoEventId { get; set; } public bool NostrProfileCreated { get; set; } - + + + /// + /// The total amount of the project that has been invested in, + /// This parameter will only be set once the founder went to the spend page + /// and was able to calaulate to total amount of funds that have been invested in the project. + /// + /// The intention is to use this parameter to know if the founder should be forced to release + /// the funds back to the investor by sending signature of a trx that spend coins to the investors address + /// + public decimal? TotalAvailableInvestedAmount { get; set; } + + public DateTime? ReleaseSignaturesTime { get; set; } + + public bool TargetInvestmentReached() + { + return TotalAvailableInvestedAmount >= ProjectInfo.TargetAmount; + } + + public bool ProjectDateStarted() + { + return DateTime.UtcNow > ProjectInfo.StartDate; + } + public bool NostrMetadataCreated() { return !string.IsNullOrEmpty(Metadata?.Name); diff --git a/src/Angor/Client/Models/InvestmentState.cs b/src/Angor/Client/Models/InvestmentState.cs index 311ffb1c..c7ac4191 100644 --- a/src/Angor/Client/Models/InvestmentState.cs +++ b/src/Angor/Client/Models/InvestmentState.cs @@ -5,4 +5,6 @@ public class InvestmentState public string ProjectIdentifier { get; set; } public string InvestmentTransactionHash { get; set; } public string investorPubKey { get; set; } + public string ReleaseAddress { get; set; } + } \ No newline at end of file diff --git a/src/Angor/Client/Models/InvestorProject.cs b/src/Angor/Client/Models/InvestorProject.cs index 590e14c7..d78a5fc3 100644 --- a/src/Angor/Client/Models/InvestorProject.cs +++ b/src/Angor/Client/Models/InvestorProject.cs @@ -18,6 +18,9 @@ public class InvestorProject : Project public string InvestorPublicKey { get; set; } public string InvestorNPub { get; set; } + public string ReleaseAddress { get; set; } + public string ReleaseTransactionId { get; set; } + public bool WaitingForFounderResponse() { return ReceivedFounderSignatures() == false && SignaturesInfo?.TimeOfSignatureRequest != null; diff --git a/src/Angor/Client/Pages/Invest.razor b/src/Angor/Client/Pages/Invest.razor index 79e8ae3c..cdf3fe98 100644 --- a/src/Angor/Client/Pages/Invest.razor +++ b/src/Angor/Client/Pages/Invest.razor @@ -853,25 +853,30 @@ else var words = await passwordComponent.GetWalletAsync(); - var nostrPrivateKey = _derivationOperations.DeriveProjectNostrInvestorPrivateKey(words, project.ProjectInfo.ProjectIdentifier); + var investorNostrPrivateKey = _derivationOperations.DeriveProjectNostrInvestorPrivateKey(words, project.ProjectInfo.ProjectIdentifier); + var nostrPrivateKeyHex = Encoders.Hex.EncodeData(investorNostrPrivateKey.ToBytes()); - var nostrPrivateKeyHex = Encoders.Hex.EncodeData(nostrPrivateKey.ToBytes()); + var releaseAddress = accountInfo.GetNextReceiveAddress(); + + SignRecoveryRequest signRecoveryRequest = new() + { + ProjectIdentifier = investorProject.ProjectInfo.ProjectIdentifier, + InvestmentTransactionHex = strippedInvestmentTransaction.ToHex(network.Consensus.ConsensusFactory), + ReleaseAddress = releaseAddress + }; + + var sigJson = serializer.Serialize(signRecoveryRequest); var encryptedContent = await encryption.EncryptNostrContentAsync( nostrPrivateKeyHex, investorProject.ProjectInfo.NostrPubKey, - strippedInvestmentTransaction.ToHex(network.Consensus.ConsensusFactory)); + sigJson); - var investmentSigsRequest = _SignService.RequestInvestmentSigs(new SignRecoveryRequest - { - ProjectIdentifier = investorProject.ProjectInfo.ProjectIdentifier, - EncryptedContent = encryptedContent, - NostrPubKey = investorProject.ProjectInfo.NostrPubKey, - InvestorNostrPrivateKey = nostrPrivateKeyHex - }); + var investmentSigsRequest = _SignService.RequestInvestmentSigs(encryptedContent, nostrPrivateKeyHex, investorProject.ProjectInfo.NostrPubKey); investorProject.SignaturesInfo!.TimeOfSignatureRequest = investmentSigsRequest.eventTime; investorProject.SignaturesInfo!.SignatureRequestEventId = investmentSigsRequest.eventId; investorProject.InvestorNPub = NostrPrivateKey.FromHex(nostrPrivateKeyHex).DerivePublicKey().Hex; + investorProject.ReleaseAddress = releaseAddress; storage.AddInvestmentProject(investorProject); @@ -989,7 +994,6 @@ else storage.UpdateInvestmentProject(investorProject); - await SaveInvestmentsListToNostrAsync(); var accountInfo = storage.GetAccountInfo(network.Name); var unspentInfo = SessionStorage.GetUnconfirmedInboundFunds(); @@ -1006,6 +1010,9 @@ else notificationComponent.ShowNotificationMessage("Invested in project", 5); + // this will publish to Nostr so we call it last. + await SaveInvestmentsListToNostrAsync(); + NavigationManager.NavigateTo($"/view/{project.ProjectInfo.ProjectIdentifier}"); } catch (Exception e) @@ -1030,7 +1037,14 @@ else { ProjectIdentifiers = storage.GetInvestmentProjects() .Where(x => x.InvestedInProject()) - .Select(x => new InvestmentState { ProjectIdentifier = x.ProjectInfo.ProjectIdentifier, investorPubKey = x.InvestorPublicKey, InvestmentTransactionHash = x.TransactionId }) + .Select(x => new InvestmentState + { + ProjectIdentifier = x.ProjectInfo.ProjectIdentifier, + investorPubKey = x.InvestorPublicKey, + InvestmentTransactionHash = x.TransactionId, + ReleaseAddress = x.ReleaseAddress, + + }) .ToList() }; diff --git a/src/Angor/Client/Pages/Investor.razor b/src/Angor/Client/Pages/Investor.razor index 400a707c..13973368 100644 --- a/src/Angor/Client/Pages/Investor.razor +++ b/src/Angor/Client/Pages/Investor.razor @@ -143,11 +143,10 @@ Stats.TryGetValue(project.ProjectInfo.ProjectIdentifier, out var stats); var nostrPubKey = project.ProjectInfo.NostrPubKey; investmentRequestsMap.TryGetValue(nostrPubKey, out bool hasInvestmentRequests); + releaseRequestsMap.TryGetValue(nostrPubKey, out bool hasInvestmentReleaseRequests);
- -
@@ -230,6 +229,12 @@ Pending }
+ @if (hasInvestmentReleaseRequests) + { +
+ Founder Released Funds +
+ }
+ @if (!FounderProject.TargetInvestmentReached() && FounderProject.ProjectDateStarted()) + { +
@FounderProject.ReleaseSignaturesTime.HasValue ? "release at " + FounderProject.ReleaseSignaturesTime : "not released yet"
+ + }
@@ -123,7 +128,7 @@ - @foreach (var signature in signaturesRequests.Where(_ => _ is { TransactionHex: not null, AmountToInvest: not null })) + @foreach (var signature in signaturesRequests.Where(_ => _ is { SignRecoveryRequest.InvestmentTransactionHex: not null, AmountToInvest: not null })) { @Money.Coins(signature.AmountToInvest ?? 0).ToUnit(MoneyUnit.BTC) @network.CoinTicker @@ -169,15 +174,15 @@ public string ProjectIdentifier { get; set; } public FounderProject FounderProject { get; set; } - private List signaturesRequests = new(); + private List signaturesRequests = new(); - private Dictionary signaturesRequestsApproving = new(); + private Dictionary signaturesRequestsApproving = new(); bool messagesReceived; bool scanedForApprovals; - private bool CanApproveAllSignatures => signaturesRequests != null && signaturesRequests.Any(s => s.TransactionHex != null && s.AmountToInvest != null && s.TimeApproved == null); + private bool CanApproveAllSignatures => signaturesRequests != null && signaturesRequests.Any(s => s.SignRecoveryRequest?.InvestmentTransactionHex != null && s.AmountToInvest != null && s.TimeApproved == null); private string ApproveButtonClass => CanApproveAllSignatures ? "btn-border-success" : "btn-border"; @@ -214,11 +219,11 @@ { if (passwordComponent.HasPassword()) { - await FetchSignatures(); + await FetchOrDecryptSignaturesRequest(); } else { - passwordComponent.ShowPassword(FetchSignatures); + passwordComponent.ShowPassword(FetchOrDecryptSignaturesRequest); } } } @@ -246,7 +251,7 @@ try { await FetchPendingSignatures(FounderProject); - await FetchSignatures(); + await FetchOrDecryptSignaturesRequest(); await Task.Delay(1000); } catch (Exception e) @@ -261,7 +266,7 @@ } } - protected async Task FetchSignatures() + protected async Task FetchOrDecryptSignaturesRequest() { Logger.LogDebug($"handled = {signaturesRequests.Count(x => x.AmountToInvest.HasValue)}, total = {signaturesRequests.Count}"); @@ -289,29 +294,37 @@ { try { - pendingSignature.TransactionHex = await encryption.DecryptNostrContentAsync( + var sigResJson = await encryption.DecryptNostrContentAsync( nostrPrivateKeyHex, pendingSignature.investorNostrPubKey, pendingSignature.EncryptedMessage); - var investorTrx = _networkConfiguration.GetNetwork().CreateTransaction(pendingSignature.TransactionHex); + pendingSignature.SignRecoveryRequest = serializer.Deserialize(sigResJson); + + if (pendingSignature.SignRecoveryRequest is null) + { + throw new Exception("Error deserializing signature request"); + } + + var investorTrx = _networkConfiguration.GetNetwork().CreateTransaction(pendingSignature.SignRecoveryRequest.InvestmentTransactionHex); pendingSignature.AmountToInvest = investorTrx.Outputs.AsIndexedOutputs().Skip(2).Take(investorTrx.Outputs.Count - 3) //Todo get the actual outputs with taproot type .Sum(_ => _.TxOut.Value.ToUnit(MoneyUnit.BTC)); } catch (FormatException fe) { - Logger.LogError("Format error decrypting transaction hex: {TransactionHex}, Exception: {ExceptionMessage}", pendingSignature?.TransactionHex, fe.Message); - pendingSignature.TransactionHex = null; + Logger.LogError("Format error decrypting transaction hex: {TransactionHex}, Exception: {ExceptionMessage}", pendingSignature?.SignRecoveryRequest?.InvestmentTransactionHex, fe.Message); + pendingSignature.SignRecoveryRequest = null; } catch (CryptographicException ce) { - Logger.LogError("Cryptographic error decrypting transaction hex: {TransactionHex}, Exception: {ExceptionMessage}", pendingSignature?.TransactionHex, ce.Message); - pendingSignature.TransactionHex = null; + Logger.LogError("Cryptographic error decrypting transaction hex: {TransactionHex}, Exception: {ExceptionMessage}", pendingSignature?.SignRecoveryRequest?.InvestmentTransactionHex, ce.Message); + pendingSignature.SignRecoveryRequest = null; } catch (Exception e) { - Logger.LogError("Error decrypting transaction hex: {TransactionHex}, Exception: {ExceptionMessage}", pendingSignature?.TransactionHex, e.Message); - pendingSignature.TransactionHex = null; - } } + Logger.LogError("Error decrypting transaction hex: {TransactionHex}, Exception: {ExceptionMessage}", pendingSignature?.SignRecoveryRequest?.InvestmentTransactionHex, e.Message); + pendingSignature.SignRecoveryRequest = null; + } + } Logger.LogDebug($"Calling StateHasChanged in OnAfterRenderAsync"); messagesReceived = false; StateHasChanged(); @@ -347,7 +360,7 @@ messagesReceived = true; - var signatureRequest = new SignatureRequest + var signatureRequest = new SignatureItem { investorNostrPubKey = investorNostrPubKey, TimeArrived = timeArrived, @@ -423,7 +436,7 @@ }); } - protected async Task ApproveSignatureCheckPassword(SignatureRequest signature) + protected async Task ApproveSignatureCheckPassword(SignatureItem signature) { if (passwordComponent.HasPassword()) { @@ -438,7 +451,7 @@ } } - private async Task ApproveSignature(SignatureRequest signature) + private async Task ApproveSignature(SignatureItem signature) { signaturesRequestsApproving.Add(signature, string.Empty); StateHasChanged(); @@ -488,7 +501,7 @@ try { - var pendingSignatures = signaturesRequests.Where(s => s.TransactionHex != null && s.AmountToInvest != null && s.TimeApproved == null).ToList(); + var pendingSignatures = signaturesRequests.Where(s => s.SignRecoveryRequest?.InvestmentTransactionHex != null && s.AmountToInvest != null && s.TimeApproved == null).ToList(); numOfSignatureToSign = pendingSignatures.Count; numOfSignaturesSigned = 0; @@ -514,12 +527,12 @@ StateHasChanged(); } - private async Task PerformSignatureApproval(SignatureRequest signature, WalletWords words) + private async Task PerformSignatureApproval(SignatureItem signature, WalletWords words) { try { var key = DerivationOperations.DeriveFounderRecoveryPrivateKey(words, FounderProject.ProjectIndex); - var signatureInfo = signProject(signature.TransactionHex, FounderProject.ProjectInfo, Encoders.Hex.EncodeData(key.ToBytes())); + var signatureInfo = CreateRecoverySignatures(signature.SignRecoveryRequest.InvestmentTransactionHex, FounderProject.ProjectInfo, Encoders.Hex.EncodeData(key.ToBytes())); var sigJson = serializer.Serialize(signatureInfo); @@ -544,7 +557,7 @@ } } - private SignatureInfo signProject(string transactionHex, ProjectInfo info, string founderSigningPrivateKey) + private SignatureInfo CreateRecoverySignatures(string transactionHex, ProjectInfo info, string founderSigningPrivateKey) { var investorTrx = _networkConfiguration.GetNetwork().CreateTransaction(transactionHex); @@ -555,10 +568,103 @@ if (!InvestorTransactionActions.CheckInvestorRecoverySignatures(info, investorTrx, sig)) throw new InvalidOperationException(); + sig.SignatureType = "recover"; + return sig; } - public class SignatureRequest + private async Task ReleaseAllSignatures() + { + if (passwordComponent.HasPassword()) + { + await ProcessReleaseSignatures(); + } + else + { + passwordComponent.ShowPassword(async () => + { + await ProcessReleaseSignatures(); + }); + } + } + + private async Task ProcessReleaseSignatures() + { + isLoading = true; + StateHasChanged(); + + try + { + var releaseSignatures = signaturesRequests.Where(s => s.SignRecoveryRequest?.InvestmentTransactionHex != null && s.AmountToInvest != null && s.TimeApproved == null).ToList(); + numOfSignatureToSign = releaseSignatures.Count; + numOfSignaturesSigned = 0; + + var words = await passwordComponent.GetWalletAsync(); + + foreach (var signature in releaseSignatures) + { + await PerformReleaseSignature(signature, words); + numOfSignaturesSigned++; + StateHasChanged(); + } + } + catch (Exception e) + { + notificationComponent.ShowErrorMessage(e.Message,e); + } + finally + { + isLoading = false; + passwordComponent.ClearPassword(); + } + + StateHasChanged(); + } + + private async Task PerformReleaseSignature(SignatureItem signature, WalletWords words) + { + try + { + var key = DerivationOperations.DeriveFounderRecoveryPrivateKey(words, FounderProject.ProjectIndex); + var signatureInfo = CreateReleaseSignatures(signature.SignRecoveryRequest?.InvestmentTransactionHex, FounderProject.ProjectInfo, Encoders.Hex.EncodeData(key.ToBytes()), signature.SignRecoveryRequest?.ReleaseAddress ?? signature.SignRecoveryRequest.ReleaseKey); + + var sigJson = serializer.Serialize(signatureInfo); + + var nostrPrivateKey = await DerivationOperations.DeriveProjectNostrPrivateKeyAsync(words, FounderProject.ProjectIndex); + var nostrPrivateKeyHex = Encoders.Hex.EncodeData(nostrPrivateKey.ToBytes()); + + var encryptedContent = await encryption.EncryptNostrContentAsync( + nostrPrivateKeyHex, signature.investorNostrPubKey, sigJson); + + FounderProject.ReleaseSignaturesTime = SignService.SendReleaseSigsToInvestor(encryptedContent, nostrPrivateKeyHex, signature.investorNostrPubKey, signature.EventId); + + Storage.UpdateFounderProject(FounderProject); + + return new OperationResult { Success = true }; + } + catch (Exception ex) + { + return new OperationResult { Success = false, Message = $"An error occurred: {ex.Message}" }; + } + } + + private SignatureInfo CreateReleaseSignatures(string transactionHex, ProjectInfo info, string founderSigningPrivateKey, string investorReleaseAddress) + { + var investorTrx = _networkConfiguration.GetNetwork().CreateTransaction(transactionHex); + + // build sigs + var recoveryTrx = InvestorTransactionActions.BuildReleaseInvestorFundsTransaction(info, investorTrx, investorReleaseAddress); + var sig = FounderTransactionActions.SignInvestorRecoveryTransactions(info, transactionHex, recoveryTrx, founderSigningPrivateKey); + + if (!InvestorTransactionActions.CheckInvestorReleaseSignatures(info, investorTrx, sig, investorReleaseAddress)) + throw new InvalidOperationException(); + + sig.SignatureType = "release"; + + return sig; + } + + public class SignatureItem { public string investorNostrPubKey { get; set; } @@ -567,7 +673,8 @@ public DateTime TimeArrived { get; set; } public DateTime? TimeApproved { get; set; } - public string? TransactionHex { get; set; } + public SignRecoveryRequest? SignRecoveryRequest { get; set; } + public string? EncryptedMessage { get; set; } public string EventId { get; set; } diff --git a/src/Angor/Client/Pages/Spend.razor b/src/Angor/Client/Pages/Spend.razor index a6851171..23335bf6 100644 --- a/src/Angor/Client/Pages/Spend.razor +++ b/src/Angor/Client/Pages/Spend.razor @@ -11,6 +11,7 @@ @using Angor.Shared.Utilities @using ITransactionSignature = NBitcoin.ITransactionSignature @using System.Text.Json +@using Angor.Client.Models @inject IClientStorage storage; @@ -20,6 +21,7 @@ @inject IDerivationOperations _derivationOperations @inject IWalletOperations _WalletOperations @inject IFounderTransactionActions _founderTransactionActions +@inject IInvestorTransactionActions _investorTransactionActions @inject ILogger Logger; @inject IClipboardService _clipboardService @@ -62,10 +64,6 @@
- - - -
@if (firstTimeRefreshSpinner && refreshSpinner) @@ -78,6 +76,15 @@ } else { + @if (!founderProject.TargetInvestmentReached() && founderProject.ProjectDateStarted()) + { + + } +
@@ -480,6 +487,7 @@ TimeSpan? timeUntilNextStage; private ProjectInfo project; + private FounderProject founderProject; private FeeData feeData = new(); @@ -512,9 +520,8 @@ protected override async Task OnInitializedAsync() { - - - project = storage.GetFounderProjects().FirstOrDefault(p => p.ProjectInfo.ProjectIdentifier == ProjectId)?.ProjectInfo; + founderProject = storage.GetFounderProjects().FirstOrDefault(p => p.ProjectInfo.ProjectIdentifier == ProjectId); + project = founderProject?.ProjectInfo; firstTimeRefreshSpinner = true; @@ -555,6 +562,12 @@ currentWithdrawableAmount += availableInvestedAmount; } } + + if (totalAvailableInvestedAmount != founderProject.TotalAvailableInvestedAmount) + { + founderProject.TotalAvailableInvestedAmount = totalAvailableInvestedAmount; + storage.UpdateFounderProject(founderProject); + } } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -699,6 +712,12 @@ protected async Task ClaimCoinsCheckPassword(int stageId) { + if (!founderProject.TargetInvestmentReached()) + { + notificationComponent.ShowErrorMessage("Target investment amount has not been reached, you can only release the funds back to the investors"); + return; + } + if (passwordComponent.HasPassword()) { await ClaimCoins(stageId); @@ -968,4 +987,9 @@ { showRawTransactionModal = isVisible; } + + private void ReleaseFundsToInvestors(MouseEventArgs e) + { + NavigationManager.NavigateTo($"/signatures/{ProjectId}"); + } } \ No newline at end of file diff --git a/src/Angor/Client/Program.cs b/src/Angor/Client/Program.cs index 38ff06ce..9e3a5d7c 100644 --- a/src/Angor/Client/Program.cs +++ b/src/Angor/Client/Program.cs @@ -64,4 +64,6 @@ builder.Services.AddScoped(); +builder.Services.AddScoped(); + await builder.Build().RunAsync(); \ No newline at end of file diff --git a/src/Angor/Client/Services/IWalletUIService.cs b/src/Angor/Client/Services/IWalletUIService.cs new file mode 100644 index 00000000..354e333b --- /dev/null +++ b/src/Angor/Client/Services/IWalletUIService.cs @@ -0,0 +1,9 @@ +using Blockcore.Consensus.TransactionInfo; + +namespace Angor.Client.Services +{ + public interface IWalletUIService + { + void AddTransactionToPending(Transaction transaction); + } +} diff --git a/src/Angor/Client/Services/WalletUIService.cs b/src/Angor/Client/Services/WalletUIService.cs new file mode 100644 index 00000000..3b0f5aef --- /dev/null +++ b/src/Angor/Client/Services/WalletUIService.cs @@ -0,0 +1,41 @@ +using Angor.Client.Storage; +using Angor.Shared; +using Angor.Shared.Models; +using Blockcore.Consensus.TransactionInfo; +using Microsoft.Extensions.Logging; + +namespace Angor.Client.Services +{ + public class WalletUIService : IWalletUIService + { + private readonly INetworkConfiguration _networkConfiguration; + private readonly IClientStorage _storage; + private readonly ICacheStorage _cacheStorage; + private readonly IWalletOperations _walletOperations; + private readonly ILogger _logger; + + public WalletUIService(INetworkConfiguration networkConfiguration, IClientStorage storage, ICacheStorage cacheStorage, IWalletOperations walletOperations, ILogger logger) + { + _networkConfiguration = networkConfiguration; + _storage = storage; + _cacheStorage = cacheStorage; + _walletOperations = walletOperations; + _logger = logger; + } + + public void AddTransactionToPending(Transaction transaction) + { + var networkName = _networkConfiguration.GetNetwork().Name; + var accountInfo = _storage.GetAccountInfo(networkName); + var unconfirmedInbound = _cacheStorage.GetUnconfirmedInboundFunds(); + var unconfirmedOutbound = _cacheStorage.GetUnconfirmedOutboundFunds(); + + unconfirmedInbound.AddRange(_walletOperations.UpdateAccountUnconfirmedInfoWithSpentTransaction(accountInfo, transaction)); + unconfirmedOutbound.AddRange(transaction.Inputs.Select(_ => new Outpoint(_.PrevOut.Hash.ToString(), (int)_.PrevOut.N))); + + _storage.SetAccountInfo(networkName, accountInfo); + _cacheStorage.SetUnconfirmedInboundFunds(unconfirmedInbound); + _cacheStorage.SetUnconfirmedOutboundFunds(unconfirmedOutbound); + } + } +} \ No newline at end of file diff --git a/src/Angor/Shared/Models/SignRecoveryRequest.cs b/src/Angor/Shared/Models/SignRecoveryRequest.cs index 6abe8e8d..8685785d 100644 --- a/src/Angor/Shared/Models/SignRecoveryRequest.cs +++ b/src/Angor/Shared/Models/SignRecoveryRequest.cs @@ -4,10 +4,17 @@ public class SignRecoveryRequest { public string ProjectIdentifier { get; set; } - public string InvestorNostrPrivateKey { get; set; } - public string NostrPubKey { get; set; } - - public string InvestmentTransaction { get; set; } + /// + /// An address that will be used to release the funds + /// to the investor in case the target amount is not reached. + /// + public string ReleaseAddress{ get; set; } - public string EncryptedContent { get; set; } + /// + /// A pubkey that will be converted by the founder to an address that will be used + /// to release the funds to the investor in case the target amount is not reached. + /// + public string ReleaseKey { get; set; } + + public string InvestmentTransactionHex { get; set; } } \ No newline at end of file diff --git a/src/Angor/Shared/Models/SignatureInfo.cs b/src/Angor/Shared/Models/SignatureInfo.cs index 97b8a25d..916f952c 100644 --- a/src/Angor/Shared/Models/SignatureInfo.cs +++ b/src/Angor/Shared/Models/SignatureInfo.cs @@ -6,4 +6,9 @@ public class SignatureInfo public List Signatures { get; set; } = new(); public DateTime? TimeOfSignatureRequest { get; set; } public string? SignatureRequestEventId { get; set; } + + /// + /// Specify whether this a release or recovery signature type. + /// + public string SignatureType { get; set; } } \ No newline at end of file diff --git a/src/Angor/Shared/ProtocolNew/FounderTransactionActions.cs b/src/Angor/Shared/ProtocolNew/FounderTransactionActions.cs index 6c1c3f38..e91c4e49 100644 --- a/src/Angor/Shared/ProtocolNew/FounderTransactionActions.cs +++ b/src/Angor/Shared/ProtocolNew/FounderTransactionActions.cs @@ -55,8 +55,6 @@ public SignatureInfo SignInvestorRecoveryTransactions(ProjectInfo projectInfo, s SignatureInfo info = new SignatureInfo { ProjectIdentifier = projectInfo.ProjectIdentifier }; - AssemblyLogger.LogAssemblyVersion(key.GetType(), _logger); - // todo: david change to Enumerable.Range for (var stageIndex = 0; stageIndex < projectInfo.Stages.Count; stageIndex++) { diff --git a/src/Angor/Shared/ProtocolNew/IInvestorTransactionActions.cs b/src/Angor/Shared/ProtocolNew/IInvestorTransactionActions.cs index 6377e0a4..8d2cf90a 100644 --- a/src/Angor/Shared/ProtocolNew/IInvestorTransactionActions.cs +++ b/src/Angor/Shared/ProtocolNew/IInvestorTransactionActions.cs @@ -7,14 +7,17 @@ public interface IInvestorTransactionActions { Transaction CreateInvestmentTransaction(ProjectInfo projectInfo, string investorKey, long totalInvestmentAmount); Transaction BuildRecoverInvestorFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction); + Transaction BuildReleaseInvestorFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction, string investorReleaseKey); + TransactionInfo RecoverEndOfProjectFunds(string transactionHex, ProjectInfo projectInfo, int stageIndex, string investorReceiveAddress, string investorPrivateKey, FeeEstimation feeEstimation); TransactionInfo RecoverRemainingFundsWithOutPenalty(string transactionHex, ProjectInfo projectInfo, int stageIndex, string investorReceiveAddress, string investorPrivateKey, FeeEstimation feeEstimation, IEnumerable seederSecrets); TransactionInfo BuildAndSignRecoverReleaseFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction, Transaction recoveryTransaction, string investorReceiveAddress, FeeEstimation feeEstimation, string investorPrivateKey); Transaction AddSignaturesToRecoverSeederFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction, SignatureInfo founderSignatures, string investorPrivateKey); + Transaction AddSignaturesToReleaseFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction, SignatureInfo founderSignatures, string investorPrivateKey, string investorReleaseKey); bool CheckInvestorRecoverySignatures(ProjectInfo projectInfo, Transaction investmentTransaction, SignatureInfo founderSignatures); - + bool CheckInvestorReleaseSignatures(ProjectInfo projectInfo, Transaction investmentTransaction, SignatureInfo founderSignatures, string investorReleaseKey); ProjectScriptType DiscoverUsedScript(ProjectInfo projectInfo, Transaction investmentTransaction, int stageIndex, string witScript); } \ No newline at end of file diff --git a/src/Angor/Shared/ProtocolNew/InvestorTransactionActions.cs b/src/Angor/Shared/ProtocolNew/InvestorTransactionActions.cs index 4378d493..7d179d1f 100644 --- a/src/Angor/Shared/ProtocolNew/InvestorTransactionActions.cs +++ b/src/Angor/Shared/ProtocolNew/InvestorTransactionActions.cs @@ -93,6 +93,14 @@ public Transaction BuildRecoverInvestorFundsTransaction(ProjectInfo projectInfo, return _investmentTransactionBuilder.BuildUpfrontRecoverFundsTransaction(projectInfo, investmentTransaction, projectInfo.PenaltyDays, investorKey); } + public Transaction BuildReleaseInvestorFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction, string investorReleaseKey) + { + // todo: build the release trx + //var (investorKey, secretHash) = _projectScriptsBuilder.GetInvestmentDataFromOpReturnScript(investmentTransaction.Outputs.First(_ => _.ScriptPubKey.IsUnspendable).ScriptPubKey); + + return _investmentTransactionBuilder.BuildUpfrontReleaseFundsTransaction(projectInfo, investmentTransaction, projectInfo.PenaltyDays, investorReleaseKey); + } + public TransactionInfo BuildAndSignRecoverReleaseFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction, Transaction recoveryTransaction, string investorReceiveAddress, FeeEstimation feeEstimation, string investorPrivateKey) { @@ -222,7 +230,7 @@ public TransactionInfo RecoverRemainingFundsWithOutPenalty(string transactionHex }); } - public Transaction AddSignaturesToRecoverSeederFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction, SignatureInfo founderSignatures, string investorPrivateKey) + public Transaction AddSignaturesToRecoverSeederFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction, SignatureInfo founderSignatures, string investorPrivateKey) { var (investorKey, secretHash) = _projectScriptsBuilder.GetInvestmentDataFromOpReturnScript(investmentTransaction.Outputs.First(_ => _.ScriptPubKey.IsUnspendable).ScriptPubKey); @@ -264,7 +272,49 @@ public Transaction AddSignaturesToRecoverSeederFundsTransaction(ProjectInfo proj return recoveryTransaction; } - public bool CheckInvestorRecoverySignatures(ProjectInfo projectInfo, Transaction investmentTransaction, SignatureInfo founderSignatures) + public Transaction AddSignaturesToReleaseFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction, SignatureInfo founderSignatures, string investorPrivateKey, string investorReleaseKey) + { + var (investorKey, secretHash) = _projectScriptsBuilder.GetInvestmentDataFromOpReturnScript(investmentTransaction.Outputs.First(_ => _.ScriptPubKey.IsUnspendable).ScriptPubKey); + + var releaseFundsTransaction = _investmentTransactionBuilder.BuildUpfrontReleaseFundsTransaction(projectInfo, investmentTransaction, projectInfo.PenaltyDays, investorReleaseKey); + + var nbitcoinNetwork = NetworkMapper.Map(_networkConfiguration.GetNetwork()); + var nbitcoinRecoveryTransaction = NBitcoin.Transaction.Parse(releaseFundsTransaction.ToHex(), nbitcoinNetwork); + var nbitcoinInvestmentTransaction = NBitcoin.Transaction.Parse(investmentTransaction.ToHex(), nbitcoinNetwork); + + var key = new NBitcoin.Key(Encoders.Hex.DecodeData(investorPrivateKey)); + var sigHash = TaprootSigHash.Single | TaprootSigHash.AnyoneCanPay; + + var outputs = nbitcoinInvestmentTransaction.Outputs.AsIndexedOutputs() + .Skip(2).Take(projectInfo.Stages.Count) + .Select(_ => _.TxOut) + .ToArray(); + + // todo: david change to Enumerable.Range + for (var stageIndex = 0; stageIndex < projectInfo.Stages.Count; stageIndex++) + { + var scriptStages = _investmentScriptBuilder.BuildProjectScriptsForStage(projectInfo, investorKey, stageIndex, secretHash); + var controlBlock = _taprootScriptBuilder.CreateControlBlock(scriptStages, _ => _.Recover); + + var execData = new TaprootExecutionData(stageIndex, new NBitcoin.Script(scriptStages.Recover.ToBytes()).TaprootV1LeafHash) { SigHash = sigHash }; + var hash = nbitcoinRecoveryTransaction.GetSignatureHashTaproot(outputs, execData); + + _logger.LogInformation($"project={projectInfo.ProjectIdentifier}; investor-pubkey={key.PubKey.ToHex()}; stage={stageIndex}; hash={hash}"); + + var investorSignature = key.SignTaprootKeySpend(hash, sigHash); + + releaseFundsTransaction.Inputs[stageIndex].WitScript = new Blockcore.Consensus.TransactionInfo.WitScript( + new WitScript( + Op.GetPushOp(investorSignature.ToBytes()), + Op.GetPushOp(TaprootSignature.Parse(founderSignatures.Signatures.First(f => f.StageIndex == stageIndex).Signature).ToBytes()), + Op.GetPushOp(scriptStages.Recover.ToBytes()), + Op.GetPushOp(controlBlock.ToBytes())).ToBytes()); + } + + return releaseFundsTransaction; + } + + public bool CheckInvestorRecoverySignatures(ProjectInfo projectInfo, Transaction investmentTransaction, SignatureInfo founderSignatures) { var (investorKey, secretHash) = _projectScriptsBuilder.GetInvestmentDataFromOpReturnScript(investmentTransaction.Outputs.First(_ => _.ScriptPubKey.IsUnspendable).ScriptPubKey); @@ -282,8 +332,6 @@ public bool CheckInvestorRecoverySignatures(ProjectInfo projectInfo, Transaction .Select(_ => _.TxOut) .ToArray(); - AssemblyLogger.LogAssemblyVersion(pubkey.GetType(), _logger); - // todo: David change to Enumerable.Range bool failedValidation = false; for (var stageIndex = 0; stageIndex < projectInfo.Stages.Count; stageIndex++) @@ -307,4 +355,46 @@ public bool CheckInvestorRecoverySignatures(ProjectInfo projectInfo, Transaction return true; } + + public bool CheckInvestorReleaseSignatures(ProjectInfo projectInfo, Transaction investmentTransaction, SignatureInfo founderSignatures, string investorReleaseKey) + { + var (investorKey, secretHash) = _projectScriptsBuilder.GetInvestmentDataFromOpReturnScript(investmentTransaction.Outputs.First(_ => _.ScriptPubKey.IsUnspendable).ScriptPubKey); + + var releaseFundsTransaction = _investmentTransactionBuilder.BuildUpfrontReleaseFundsTransaction(projectInfo, investmentTransaction, projectInfo.PenaltyDays, investorReleaseKey); + + var nbitcoinNetwork = NetworkMapper.Map(_networkConfiguration.GetNetwork()); + var nBitcoinRecoveryTransaction = NBitcoin.Transaction.Parse(releaseFundsTransaction.ToHex(), nbitcoinNetwork); + var nbitcoinInvestmentTransaction = NBitcoin.Transaction.Parse(investmentTransaction.ToHex(), nbitcoinNetwork); + + var pubkey = new NBitcoin.PubKey(projectInfo.FounderRecoveryKey).GetTaprootFullPubKey(); + var sigHash = TaprootSigHash.Single | TaprootSigHash.AnyoneCanPay; + + var outputs = nbitcoinInvestmentTransaction.Outputs.AsIndexedOutputs() + .Skip(2).Take(projectInfo.Stages.Count) + .Select(_ => _.TxOut) + .ToArray(); + + // todo: David change to Enumerable.Range + bool failedValidation = false; + for (var stageIndex = 0; stageIndex < projectInfo.Stages.Count; stageIndex++) + { + var scriptStages = _investmentScriptBuilder.BuildProjectScriptsForStage(projectInfo, investorKey, stageIndex, secretHash); + + var execData = new TaprootExecutionData(stageIndex, new NBitcoin.Script(scriptStages.Recover.ToBytes()).TaprootV1LeafHash) { SigHash = sigHash }; + var hash = nBitcoinRecoveryTransaction.GetSignatureHashTaproot(outputs, execData); + var sig = founderSignatures.Signatures.First(f => f.StageIndex == stageIndex).Signature; + + var result = pubkey.VerifySignature(hash, TaprootSignature.Parse(sig).SchnorrSignature); + + _logger.LogInformation($"verifying sig for project={projectInfo.ProjectIdentifier}; success = {result}; founder-recovery-pubkey={projectInfo.FounderRecoveryKey}; stage={stageIndex}; hash={hash}; signature-hex={sig}"); + + if (result == false) + failedValidation = true; + } + + if (failedValidation) + throw new Exception("Invalid signatures provided by founder"); + + return true; + } } \ No newline at end of file diff --git a/src/Angor/Shared/ProtocolNew/TransactionBuilders/IInvestmentTransactionBuilder.cs b/src/Angor/Shared/ProtocolNew/TransactionBuilders/IInvestmentTransactionBuilder.cs index 56031858..b199b30f 100644 --- a/src/Angor/Shared/ProtocolNew/TransactionBuilders/IInvestmentTransactionBuilder.cs +++ b/src/Angor/Shared/ProtocolNew/TransactionBuilders/IInvestmentTransactionBuilder.cs @@ -11,4 +11,7 @@ Transaction BuildInvestmentTransaction(ProjectInfo projectInfo, Script opReturnS Transaction BuildUpfrontRecoverFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction, int penaltyDays, string investorKey); + + Transaction BuildUpfrontReleaseFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction, int penaltyDays, + string investorReleaseKey); } \ No newline at end of file diff --git a/src/Angor/Shared/ProtocolNew/TransactionBuilders/InvestmentTransactionBuilder.cs b/src/Angor/Shared/ProtocolNew/TransactionBuilders/InvestmentTransactionBuilder.cs index 5a80f61f..35ccab50 100644 --- a/src/Angor/Shared/ProtocolNew/TransactionBuilders/InvestmentTransactionBuilder.cs +++ b/src/Angor/Shared/ProtocolNew/TransactionBuilders/InvestmentTransactionBuilder.cs @@ -68,4 +68,30 @@ public Transaction BuildUpfrontRecoverFundsTransaction(ProjectInfo projectInfo, return transaction; } + + public Transaction BuildUpfrontReleaseFundsTransaction(ProjectInfo projectInfo, Transaction investmentTransaction, int penaltyDays, string investorReleaseKey) + { + // the release may be an address or a pubkey, first check if it is an address + Script spendingScript = null; + if (BitcoinWitPubKeyAddress.IsValid(investorReleaseKey, _networkConfiguration.GetNetwork(), out Exception _)) + { + spendingScript = new BitcoinWitPubKeyAddress(investorReleaseKey, _networkConfiguration.GetNetwork()).ScriptPubKey; + } + else // if it is not an address, then it is a pubkey + { + // for the release we just send to a regular witness address + spendingScript = new PubKey(investorReleaseKey).WitHash.ScriptPubKey; + } + + var transaction = _networkConfiguration.GetNetwork().CreateTransaction(); + + foreach (var output in investmentTransaction.Outputs.AsIndexedOutputs().Skip(2).Take(projectInfo.Stages.Count)) + { + transaction.Inputs.Add(new TxIn(output.ToOutPoint())); + + transaction.Outputs.Add(new TxOut(output.TxOut.Value, spendingScript)); + } + + return transaction; + } } \ No newline at end of file diff --git a/src/Angor/Shared/Services/ISignService.cs b/src/Angor/Shared/Services/ISignService.cs index f9c29cd6..876334d0 100644 --- a/src/Angor/Shared/Services/ISignService.cs +++ b/src/Angor/Shared/Services/ISignService.cs @@ -4,7 +4,7 @@ namespace Angor.Client.Services; public interface ISignService { - (DateTime eventTime, string eventId) RequestInvestmentSigs(SignRecoveryRequest signRecoveryRequest); + (DateTime eventTime, string eventId) RequestInvestmentSigs(string encryptedContent, string investorNostrPrivateKey, string founderNostrPubKey); void LookupSignatureForInvestmentRequest(string investorNostrPubKey, string projectNostrPubKey, DateTime sigRequestSentTime, string sigRequestEventId, Func action); Task LookupInvestmentRequestsAsync(string nostrPubKey, string? senderNpub, DateTime? since, Action action, @@ -15,4 +15,8 @@ void LookupInvestmentRequestApprovals(string nostrPubKey, Action action); } \ No newline at end of file diff --git a/src/Angor/Shared/Services/SignService.cs b/src/Angor/Shared/Services/SignService.cs index 68deba79..e0b06107 100644 --- a/src/Angor/Shared/Services/SignService.cs +++ b/src/Angor/Shared/Services/SignService.cs @@ -20,17 +20,17 @@ public SignService(INostrCommunicationFactory communicationFactory, INetworkServ _subscriptionsHanding = subscriptionsHanding; } - public (DateTime,string) RequestInvestmentSigs(SignRecoveryRequest signRecoveryRequest) + public (DateTime,string) RequestInvestmentSigs(string encryptedContent, string investorNostrPrivateKey, string founderNostrPubKey) { - var sender = NostrPrivateKey.FromHex(signRecoveryRequest.InvestorNostrPrivateKey); + var sender = NostrPrivateKey.FromHex(investorNostrPrivateKey); var ev = new NostrEvent { Kind = NostrKind.EncryptedDm, CreatedAt = DateTime.UtcNow, - Content = signRecoveryRequest.EncryptedContent, + Content = encryptedContent, Tags = new NostrEventTags( - NostrEventTag.Profile(signRecoveryRequest.NostrPubKey), + NostrEventTag.Profile(founderNostrPubKey), new NostrEventTag("subject","Investment offer")) }; @@ -55,6 +55,7 @@ public void LookupSignatureForInvestmentRequest(string investorNostrPubKey, stri var subscription = nostrClient.Streams.EventStream .Where(_ => _.Subscription == projectNostrPubKey) .Where(_ => _.Event.Kind == NostrKind.EncryptedDm) + .Where(_ => _.Event.Tags.FindFirstTagValue("subject") == "Re:Investment offer") .Subscribe(_ => { action.Invoke(_.Event.Content); }); _subscriptionsHanding.TryAddRelaySubscription(projectNostrPubKey, subscription); @@ -82,6 +83,7 @@ public Task LookupInvestmentRequestsAsync(string nostrPubKey, string? senderNpub { var subscription = nostrClient.Streams.EventStream .Where(_ => _.Subscription == subscriptionKey) + .Where(_ => _.Event.Tags.FindFirstTagValue("subject") == "Investment offer") .Select(_ => _.Event) .Subscribe(nostrEvent => { @@ -116,6 +118,7 @@ public void LookupInvestmentRequestApprovals(string nostrPubKey, Action _.Subscription == subscriptionKey) + .Where(_ => _.Event.Tags.FindFirstTagValue("subject") == "Re:Investment offer") .Select(_ => _.Event) .Subscribe(nostrEvent => { @@ -159,6 +162,58 @@ public DateTime SendSignaturesToInvestor(string encryptedSignatureInfo, string n return ev.CreatedAt.Value; } + public DateTime SendReleaseSigsToInvestor(string encryptedReleaseSigInfo, string nostrPrivateKeyHex, string investorNostrPubKey, string eventId) + { + var nostrPrivateKey = NostrPrivateKey.FromHex(nostrPrivateKeyHex); + + var ev = new NostrEvent + { + Kind = NostrKind.EncryptedDm, + CreatedAt = DateTime.UtcNow, + Content = encryptedReleaseSigInfo, + Tags = new NostrEventTags(new[] + { + NostrEventTag.Profile(investorNostrPubKey), + NostrEventTag.Event(eventId), + new NostrEventTag("subject", "Release transaction signatures"), + }) + }; + + var signed = ev.Sign(nostrPrivateKey); + + var nostrClient = _communicationFactory.GetOrCreateClient(_networkService); + nostrClient.Send(new NostrEventRequest(signed)); + + return ev.CreatedAt.Value; + } + + public void LookupReleaseSigs(string investorNostrPubKey, string projectNostrPubKey, DateTime? releaseRequestSentTime, string releaseRequestEventId, Func action) + { + var nostrClient = _communicationFactory.GetOrCreateClient(_networkService); + var subscriptionKey = projectNostrPubKey + "release_sigs"; + + if (!_subscriptionsHanding.RelaySubscriptionAdded(subscriptionKey)) + { + var subscription = nostrClient.Streams.EventStream + .Where(_ => _.Subscription == subscriptionKey) + .Where(_ => _.Event.Kind == NostrKind.EncryptedDm) + .Where(_ => _.Event.Tags.FindFirstTagValue("subject") == "Release transaction signatures") + .Subscribe(_ => { action.Invoke(_.Event.Content); }); + + _subscriptionsHanding.TryAddRelaySubscription(subscriptionKey, subscription); + } + + nostrClient.Send(new NostrRequest(subscriptionKey, new NostrFilter + { + Authors = new[] { projectNostrPubKey }, // From founder + P = new[] { investorNostrPubKey }, // To investor + Kinds = new[] { NostrKind.EncryptedDm }, + Since = releaseRequestSentTime, + E = new[] { releaseRequestEventId }, + Limit = 1, + })); + } + public void CloseConnection() { _subscriptionsHanding.Dispose();