diff --git a/src/design/App/UI/Shared/PaymentFlow/PaymentFlowConfig.cs b/src/design/App/UI/Shared/PaymentFlow/PaymentFlowConfig.cs index 6794872f1..8b48352b9 100644 --- a/src/design/App/UI/Shared/PaymentFlow/PaymentFlowConfig.cs +++ b/src/design/App/UI/Shared/PaymentFlow/PaymentFlowConfig.cs @@ -42,20 +42,10 @@ public record PaymentFlowConfig public static long EstimateOnChainRequired(long investmentAmountSats, int stageCount, int feeRateSatsPerVbyte) { const int AngorFeePercentage = 1; - long angorFee = (investmentAmountSats * AngorFeePercentage) / 100; - - // Investment tx structure (same estimate as CreateLightningSwap): - // ~10.5 vB tx overhead - // ~68 vB 1 P2WPKH input - // 43 vB 1 P2WSH output (angor fee) - // ~99 vB 1 OP_RETURN output - // N×43 vB N P2TR stage outputs - // 31 vB 1 P2WPKH change output - // Total ≈ 252 + (stageCount × 43) vbytes - int estimatedTxVbytes = 252 + (stageCount * 43); - long estimatedMinerFee = feeRateSatsPerVbyte * estimatedTxVbytes; - - return investmentAmountSats + angorFee + estimatedMinerFee; + + // Shared with the web app so invoice sizing and tx-build fee budgets never drift apart. + return Angor.Shared.Protocol.InvestmentFeeEstimator.EstimateOnChainRequired( + investmentAmountSats, AngorFeePercentage, stageCount, feeRateSatsPerVbyte); } /// Title for the invoice screen, e.g. "Pay to Invest" or "Fund Deployment". diff --git a/src/sdk/Angor.Sdk/Funding/FundingContextServices.cs b/src/sdk/Angor.Sdk/Funding/FundingContextServices.cs index 82c5eca53..910445ad5 100644 --- a/src/sdk/Angor.Sdk/Funding/FundingContextServices.cs +++ b/src/sdk/Angor.Sdk/Funding/FundingContextServices.cs @@ -30,6 +30,7 @@ public static ServiceCollection Register(ServiceCollection services, ILogger log services.AddSingleton(); + services.TryAddSingleton(); services.AddSingleton(); //services.AddSingleton(); services.AddScoped(); diff --git a/src/sdk/Angor.Sdk/Funding/Investor/Domain/PortfolioService.cs b/src/sdk/Angor.Sdk/Funding/Investor/Domain/PortfolioService.cs index e7f83bb02..0793f3d88 100644 --- a/src/sdk/Angor.Sdk/Funding/Investor/Domain/PortfolioService.cs +++ b/src/sdk/Angor.Sdk/Funding/Investor/Domain/PortfolioService.cs @@ -1,60 +1,74 @@ -using System.Collections.Concurrent; using Angor.Sdk.Common; using Angor.Sdk.Funding.Shared; using Angor.Data.Documents.Interfaces; using Angor.Shared; using Angor.Shared.Services; -using NBitcoin; -using NBitcoin.DataEncoders; using CSharpFunctionalExtensions; using Microsoft.Extensions.Logging; -using Nostr.Client.Messages; namespace Angor.Sdk.Funding.Investor.Domain; public class PortfolioService( - IEncryptionService encryptionService, - IDerivationOperations derivationOperations, ISerializer serializer, ISeedwordsProvider seedwordsProvider, - IRelayService relayService, + INostrInvestmentStorageService nostrInvestmentStorage, IGenericDocumentCollection documentCollection, ILogger logger) : IPortfolioService { public async Task> GetByWalletId(string walletId) { - // Try to get from local document collection first (no password needed) + // Try to get from local document collection first (no password needed). + // An empty local document is NOT authoritative: it may have been cached from a + // failed/timed-out relay lookup, so fall through to the relay in that case. var localDoc = await documentCollection.FindByIdAsync(walletId); - if (localDoc is { IsSuccess: true, Value: not null }) - return Result.Success(new InvestmentRecords(){ProjectIdentifiers = localDoc.Value.Investments}); + if (localDoc is { IsSuccess: true, Value.Investments.Count: > 0 }) + return Result.Success(new InvestmentRecords { ProjectIdentifiers = localDoc.Value.Investments }); - // Local not found — need wallet sensitive data to fetch from relay + // Local not found (or empty) — need wallet sensitive data to fetch from relay var sensiveDataResult = await seedwordsProvider.GetSensitiveData(walletId); if (sensiveDataResult.IsFailure) { return Result.Failure(sensiveDataResult.Error); } - + var words = sensiveDataResult.Value.ToWalletWords(); - var storageAccountKey = derivationOperations.DeriveNostrStoragePubKeyHex(words); - var password = derivationOperations.DeriveNostrStoragePassword(words); - - var relayResult = await GetInvestmentRecordsFromRelayAsync(storageAccountKey, password); + + var relayResult = await nostrInvestmentStorage.LoadAsync(words); if (relayResult.IsFailure) { - return relayResult; + return Result.Failure(relayResult.Error); + } + + if (relayResult.Value is null) + { + // Nothing on the relays (or nothing decryptable). Do NOT cache the empty + // result — a transient relay failure must not permanently hide investments. + return Result.Success(new InvestmentRecords()); + } + + InvestmentRecords? records; + try + { + records = serializer.Deserialize(relayResult.Value); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to deserialize investment records payload from relay"); + return Result.Success(new InvestmentRecords()); } + records ??= new InvestmentRecords(); + // Save to local document collection for future lookups var doc = new InvestmentRecordsDocument { WalletId = walletId, - Investments = relayResult.Value?.ProjectIdentifiers.ToList() ?? [] + Investments = records.ProjectIdentifiers.ToList() }; - + await documentCollection.UpsertAsync(document => document.WalletId, doc); - return relayResult; + return Result.Success(records); } public async Task AddOrUpdate(string walletId, InvestmentRecord investmentRecord) @@ -112,161 +126,25 @@ public async Task RemoveInvestmentRecordAsync(string walletId, Investmen var savedLocally = await documentCollection.UpsertAsync(document => document.WalletId, doc); - return savedLocally.IsSuccess + // Push the updated (possibly empty) list to the relay as well. Without this, the + // relay would keep the removed investment and any lookup that falls back to the + // relay (fresh import, empty local cache) would resurrect the cancelled record. + var savedOnRelay = await PushInvestmentsRecordsToRelayAsync(walletId, investments); + + return savedLocally.IsSuccess || savedOnRelay.IsSuccess ? Result.Success() : Result.Failure("Failed to save investment record"); } - private async Task> PushInvestmentsRecordsToRelayAsync(string walletId, InvestmentRecords investments) + private async Task PushInvestmentsRecordsToRelayAsync(string walletId, InvestmentRecords investments) { - // // Encrypt and send the investments var sensiveDataResult = await seedwordsProvider.GetSensitiveData(walletId); if (sensiveDataResult.IsFailure) { - return Result.Failure(sensiveDataResult.Error); + return Result.Failure(sensiveDataResult.Error); } var words = sensiveDataResult.Value.ToWalletWords(); - var storageAccountKey = derivationOperations.DeriveNostrStoragePubKeyHex(words); - var storageKey = derivationOperations.DeriveNostrStorageKey(words); - var storageKeyHex = Encoders.Hex.EncodeData(storageKey.ToBytes()); - var password = derivationOperations.DeriveNostrStoragePassword(words); - - var encrypted = await encryptionService.EncryptData(serializer.Serialize(investments), password); - - var tcs = new TaskCompletionSource(); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - cts.Token.Register(() => tcs.TrySetResult(false)); - relayService.SendDirectMessagesForPubKeyAsync(storageKeyHex, storageAccountKey, encrypted, result => { tcs.TrySetResult(result.Accepted); }); - - var success = await tcs.Task; - return success ? Result.Success(true) : Result.Failure("Failed to push investment records to relay"); - } - - private async Task> GetInvestmentRecordsFromRelayAsync(string storageAccountKey, - string password) - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - var tcs = new TaskCompletionSource(); - - cts.Token.Register(() => tcs.TrySetCanceled()); - - // Collect events from all relays, keyed by content to deduplicate. - // Different relays may return the same event or different versions - // (e.g. stale data from a previous session that used a different key). - var receivedEvents = new ConcurrentDictionary(); - - relayService.LookupDirectMessagesForPubKey(storageAccountKey, null, 1, nostrEvent => - { - try - { - if (!string.IsNullOrEmpty(nostrEvent.Content)) - { - // Keep the newest event per unique content payload - receivedEvents.AddOrUpdate( - nostrEvent.Content, - nostrEvent, - (_, existing) => nostrEvent.CreatedAt > existing.CreatedAt ? nostrEvent : existing); - } - - tcs.TrySetResult(Result.Success()); - } - catch (Exception e) - { - tcs.TrySetException(e); - } - - return tcs.Task; - }, new[] { storageAccountKey }, false, - () => - { - tcs.TrySetResult(Result.Success()); - }); - - try - { - await tcs.Task; - } - catch (OperationCanceledException) - { - // Timeout — but we may have collected events before the timeout fired. - // Fall through to process whatever we have. - } - - // Process whatever events we collected (even if the relay lookup timed out) - return await TryDecryptRelayEvents(receivedEvents, storageAccountKey, password); - } - - private async Task> TryDecryptRelayEvents( - ConcurrentDictionary receivedEvents, - string storageAccountKey, - string password) - { - // Sort unique payloads by timestamp, newest first - var uniqueEvents = receivedEvents.Values - .OrderByDescending(e => e.CreatedAt ?? DateTime.MinValue) - .ToList(); - - if (uniqueEvents.Count == 0) - return Result.Success(new InvestmentRecords()); - - if (uniqueEvents.Count > 1) - { - logger.LogWarning( - "Received {UniqueCount} distinct relay payloads for storage key {StorageKey}. " + - "Timestamps: {Timestamps}. Content lengths: {Lengths}. " + - "This may indicate stale data on some relays from a previous session", - uniqueEvents.Count, - storageAccountKey[..12] + "...", - string.Join(", ", uniqueEvents.Select(e => e.CreatedAt?.ToString("O") ?? "null")), - string.Join(", ", uniqueEvents.Select(e => e.Content?.Length ?? 0))); - } - - // Try decrypting each unique payload starting from the newest. - // If the newest fails (e.g. stale data encrypted with a different key), - // fall back to older payloads that may still be valid. - for (var i = 0; i < uniqueEvents.Count; i++) - { - var nostrEvent = uniqueEvents[i]; - try - { - var decrypted = await encryptionService.DecryptData(nostrEvent.Content!, password); - var investmentRecords = serializer.Deserialize(decrypted); - - if (uniqueEvents.Count > 1) - { - logger.LogInformation( - "Successfully decrypted relay event from {Timestamp} (tried {Index} of {Total})", - nostrEvent.CreatedAt?.ToString("O") ?? "null", - i + 1, - uniqueEvents.Count); - } - - return Result.Success(investmentRecords!); - } - catch (Exception ex) when (ex is System.Security.Cryptography.AuthenticationTagMismatchException - or System.Security.Cryptography.CryptographicException - or FormatException) - { - logger.LogWarning(ex, - "Failed to decrypt relay event from {Timestamp} " + - "(content length={ContentLength}, payload {Index} of {Total}). " + - "This event may contain stale data encrypted with a different key", - nostrEvent.CreatedAt?.ToString("O") ?? "null", - nostrEvent.Content?.Length ?? 0, - i + 1, - uniqueEvents.Count); - } - } - - // All payloads failed to decrypt — return empty rather than crashing the pipeline - logger.LogError( - "All {Count} distinct relay payloads for storage key {StorageKey} failed to decrypt. " + - "Returning empty investment records. The relay may contain stale data " + - "from a previous session that used a different encryption key", - uniqueEvents.Count, - storageAccountKey[..12] + "..."); - - return Result.Success(new InvestmentRecords()); + return await nostrInvestmentStorage.SaveAsync(words, serializer.Serialize(investments)); } -} \ No newline at end of file +} diff --git a/src/shared/Angor.Shared/Protocol/InvestmentFeeEstimator.cs b/src/shared/Angor.Shared/Protocol/InvestmentFeeEstimator.cs new file mode 100644 index 000000000..66678f281 --- /dev/null +++ b/src/shared/Angor.Shared/Protocol/InvestmentFeeEstimator.cs @@ -0,0 +1,79 @@ +namespace Angor.Shared.Protocol; + +/// +/// Single source of truth for estimating the total on-chain amount a user must +/// deliver to a funding address so that an investment transaction can be built +/// and signed exclusively from UTXOs on that address. +/// Used by both the web app (Blazor) and the desktop app (via the SDK) so the +/// invoice amount and the transaction-build fee budget can never drift apart. +/// +public static class InvestmentFeeEstimator +{ + /// + /// Base investment tx size with a single P2WPKH input: + /// ~10.5 vB tx overhead + /// ~68 vB 1 P2WPKH input + /// 43 vB 1 P2WSH output (angor fee) + /// ~99 vB 1 OP_RETURN output + /// 31 vB 1 P2WPKH change output + /// + public const int BaseTxVbytes = 252; + + /// Each stage adds one P2TR output (~43 vB). + public const int PerStageVbytes = 43; + + /// Each additional P2WPKH input adds ~68 vB. + public const int PerInputVbytes = 68; + + /// + /// Headroom for the Boltz claim transaction fee. The claim tx that sweeps the + /// swap lockup into the funding address pays its own miner fee (built locally + /// at ~2 sat/vB, ~111 vB), which is deducted from the amount that lands + /// on-chain. Boltz's advertised swap fees do NOT include it. + /// + public const long LightningClaimFeeHeadroomSats = 300; + + /// Estimated virtual size of the investment transaction in vbytes. + public static int EstimateInvestmentTxVbytes(int stageCount, int inputCount = 1) + { + if (stageCount < 0) stageCount = 0; + if (inputCount < 1) inputCount = 1; + return BaseTxVbytes + (stageCount * PerStageVbytes) + ((inputCount - 1) * PerInputVbytes); + } + + /// Estimated miner fee of the investment transaction in satoshis. + public static long EstimateInvestmentTxFee(int stageCount, long feeRateSatsPerVbyte, int inputCount = 1) + { + return feeRateSatsPerVbyte * EstimateInvestmentTxVbytes(stageCount, inputCount); + } + + /// + /// Total on-chain amount required at the funding address: + /// investment amount + Angor fee + estimated investment tx miner fee. + /// + public static long EstimateOnChainRequired( + long investmentAmountSats, + int angorFeePercentage, + int stageCount, + long feeRateSatsPerVbyte, + int inputCount = 1) + { + long angorFee = (investmentAmountSats * angorFeePercentage) / 100; + return investmentAmountSats + angorFee + EstimateInvestmentTxFee(stageCount, feeRateSatsPerVbyte, inputCount); + } + + /// + /// Total amount to request when funds arrive via a Lightning (Boltz reverse swap) + /// claim: same as plus headroom for the + /// locally-built claim transaction fee that is deducted before funds land. + /// + public static long EstimateLightningRequired( + long investmentAmountSats, + int angorFeePercentage, + int stageCount, + long feeRateSatsPerVbyte) + { + return EstimateOnChainRequired(investmentAmountSats, angorFeePercentage, stageCount, feeRateSatsPerVbyte) + + LightningClaimFeeHeadroomSats; + } +} diff --git a/src/shared/Angor.Shared/Services/NostrInvestmentStorageService.cs b/src/shared/Angor.Shared/Services/NostrInvestmentStorageService.cs new file mode 100644 index 000000000..f3a901c18 --- /dev/null +++ b/src/shared/Angor.Shared/Services/NostrInvestmentStorageService.cs @@ -0,0 +1,179 @@ +using System.Collections.Concurrent; +using Angor.Shared.Models; +using CSharpFunctionalExtensions; +using Microsoft.Extensions.Logging; +using NBitcoin.DataEncoders; +using Nostr.Client.Messages; + +namespace Angor.Shared.Services; + +/// +/// Stores the investor's investments list on Nostr as an encrypted self-DM from a +/// deterministic "storage account" derived from the wallet seed words. +/// This is the single mechanism shared by the web app and the desktop/SDK app so a +/// wallet imported on either surface discovers investments made on the other. +/// The payload is an opaque serialized string; callers own the (JSON-compatible) model. +/// +public interface INostrInvestmentStorageService +{ + /// Encrypts and publishes the serialized investments payload to the relays. + Task SaveAsync(WalletWords words, string serializedInvestments); + + /// + /// Fetches, decrypts and returns the newest decryptable investments payload from the + /// relays, or null when none exists. Stale/undecryptable payloads are skipped. + /// + Task> LoadAsync(WalletWords words); +} + +public class NostrInvestmentStorageService( + IDerivationOperations derivationOperations, + IEncryptionService encryptionService, + IRelayService relayService, + ILogger logger) : INostrInvestmentStorageService +{ + private static readonly TimeSpan RelayTimeout = TimeSpan.FromSeconds(30); + + public async Task SaveAsync(WalletWords words, string serializedInvestments) + { + var storageAccountKey = derivationOperations.DeriveNostrStoragePubKeyHex(words); + var storageKey = derivationOperations.DeriveNostrStorageKey(words); + var storageKeyHex = Encoders.Hex.EncodeData(storageKey.ToBytes()); + var password = derivationOperations.DeriveNostrStoragePassword(words); + + var encrypted = await encryptionService.EncryptData(serializedInvestments, password); + + var tcs = new TaskCompletionSource(); + using var cts = new CancellationTokenSource(RelayTimeout); + cts.Token.Register(() => tcs.TrySetResult(false)); + relayService.SendDirectMessagesForPubKeyAsync(storageKeyHex, storageAccountKey, encrypted, + result => { tcs.TrySetResult(result.Accepted); }); + + var success = await tcs.Task; + return success ? Result.Success() : Result.Failure("Failed to push investment records to relay"); + } + + public async Task> LoadAsync(WalletWords words) + { + var storageAccountKey = derivationOperations.DeriveNostrStoragePubKeyHex(words); + var password = derivationOperations.DeriveNostrStoragePassword(words); + + using var cts = new CancellationTokenSource(RelayTimeout); + var tcs = new TaskCompletionSource(); + cts.Token.Register(() => tcs.TrySetCanceled()); + + // Collect events from all relays, keyed by content to deduplicate. + // Different relays may return the same event or different versions + // (e.g. stale data from a previous session that used a different key). + var receivedEvents = new ConcurrentDictionary(); + + relayService.LookupDirectMessagesForPubKey(storageAccountKey, null, 1, nostrEvent => + { + try + { + if (!string.IsNullOrEmpty(nostrEvent.Content)) + { + // Keep the newest event per unique content payload + receivedEvents.AddOrUpdate( + nostrEvent.Content, + nostrEvent, + (_, existing) => nostrEvent.CreatedAt > existing.CreatedAt ? nostrEvent : existing); + } + + tcs.TrySetResult(Result.Success()); + } + catch (Exception e) + { + tcs.TrySetException(e); + } + + return tcs.Task; + }, new[] { storageAccountKey }, false, + () => { tcs.TrySetResult(Result.Success()); }); + + try + { + await tcs.Task; + } + catch (OperationCanceledException) + { + // Timeout — but we may have collected events before the timeout fired. + // Fall through to process whatever we have. + } + + return await TryDecryptRelayEvents(receivedEvents, storageAccountKey, password); + } + + private async Task> TryDecryptRelayEvents( + ConcurrentDictionary receivedEvents, + string storageAccountKey, + string password) + { + // Sort unique payloads by timestamp, newest first + var uniqueEvents = receivedEvents.Values + .OrderByDescending(e => e.CreatedAt ?? DateTime.MinValue) + .ToList(); + + if (uniqueEvents.Count == 0) + return Result.Success(null); + + if (uniqueEvents.Count > 1) + { + logger.LogWarning( + "Received {UniqueCount} distinct relay payloads for storage key {StorageKey}. " + + "Timestamps: {Timestamps}. Content lengths: {Lengths}. " + + "This may indicate stale data on some relays from a previous session", + uniqueEvents.Count, + storageAccountKey[..12] + "...", + string.Join(", ", uniqueEvents.Select(e => e.CreatedAt?.ToString("O") ?? "null")), + string.Join(", ", uniqueEvents.Select(e => e.Content?.Length ?? 0))); + } + + // Try decrypting each unique payload starting from the newest. + // If the newest fails (e.g. stale data encrypted with a different key), + // fall back to older payloads that may still be valid. + for (var i = 0; i < uniqueEvents.Count; i++) + { + var nostrEvent = uniqueEvents[i]; + try + { + var decrypted = await encryptionService.DecryptData(nostrEvent.Content!, password); + + // The web (JS) decrypt shim returns an empty string on failure instead of throwing. + if (string.IsNullOrEmpty(decrypted)) + continue; + + if (uniqueEvents.Count > 1) + { + logger.LogInformation( + "Successfully decrypted relay event from {Timestamp} (tried {Index} of {Total})", + nostrEvent.CreatedAt?.ToString("O") ?? "null", + i + 1, + uniqueEvents.Count); + } + + return Result.Success(decrypted); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "Failed to decrypt relay event from {Timestamp} " + + "(content length={ContentLength}, payload {Index} of {Total}). " + + "This event may contain stale data encrypted with a different key", + nostrEvent.CreatedAt?.ToString("O") ?? "null", + nostrEvent.Content?.Length ?? 0, + i + 1, + uniqueEvents.Count); + } + } + + // All payloads failed to decrypt — report null rather than crashing the pipeline + logger.LogError( + "All {Count} distinct relay payloads for storage key {StorageKey} failed to decrypt. " + + "The relay may contain stale data from a previous session that used a different encryption key", + uniqueEvents.Count, + storageAccountKey[..12] + "..."); + + return Result.Success(null); + } +} diff --git a/src/webapp/Angor.Client/Pages/InvestView.razor b/src/webapp/Angor.Client/Pages/InvestView.razor index 809c497a6..44fe20355 100644 --- a/src/webapp/Angor.Client/Pages/InvestView.razor +++ b/src/webapp/Angor.Client/Pages/InvestView.razor @@ -44,6 +44,7 @@ @inject IBoltzSwapService _boltzSwapService @inject IBoltzClaimService _boltzClaimService @inject IIndexerService _indexerService +@inject INostrInvestmentStorageService _nostrInvestmentStorage @@ -585,7 +586,11 @@