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
18 changes: 4 additions & 14 deletions src/design/App/UI/Shared/PaymentFlow/PaymentFlowConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>Title for the invoice screen, e.g. "Pay to Invest" or "Fund Deployment".</summary>
Expand Down
1 change: 1 addition & 0 deletions src/sdk/Angor.Sdk/Funding/FundingContextServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public static ServiceCollection Register(ServiceCollection services, ILogger log


services.AddSingleton<IPortfolioService, PortfolioService>();
services.TryAddSingleton<INostrInvestmentStorageService, NostrInvestmentStorageService>();
services.AddSingleton<IFounderProjectsService, FounderProjectsService>();
//services.AddSingleton<IProjectRepository, ProjectRepository>();
services.AddScoped<IProjectService, DocumentProjectService>();
Expand Down
210 changes: 44 additions & 166 deletions src/sdk/Angor.Sdk/Funding/Investor/Domain/PortfolioService.cs
Original file line number Diff line number Diff line change
@@ -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<InvestmentRecordsDocument> documentCollection,
ILogger<PortfolioService> logger) : IPortfolioService
{
public async Task<Result<InvestmentRecords>> 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<InvestmentRecords>(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<InvestmentRecords>(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<InvestmentRecords>(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<Result> AddOrUpdate(string walletId, InvestmentRecord investmentRecord)
Expand Down Expand Up @@ -112,161 +126,25 @@ public async Task<Result> 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<Result<bool>> PushInvestmentsRecordsToRelayAsync(string walletId, InvestmentRecords investments)
private async Task<Result> PushInvestmentsRecordsToRelayAsync(string walletId, InvestmentRecords investments)
{
// // Encrypt and send the investments
var sensiveDataResult = await seedwordsProvider.GetSensitiveData(walletId);
if (sensiveDataResult.IsFailure)
{
return Result.Failure<bool>(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<bool>();
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<bool>("Failed to push investment records to relay");
}

private async Task<Result<InvestmentRecords>> GetInvestmentRecordsFromRelayAsync(string storageAccountKey,
string password)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var tcs = new TaskCompletionSource<Result>();

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<string, NostrEvent>();

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<Result<InvestmentRecords>> TryDecryptRelayEvents(
ConcurrentDictionary<string, NostrEvent> 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<InvestmentRecords>(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));
}
}
}
79 changes: 79 additions & 0 deletions src/shared/Angor.Shared/Protocol/InvestmentFeeEstimator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
namespace Angor.Shared.Protocol;

/// <summary>
/// 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.
/// </summary>
public static class InvestmentFeeEstimator
{
/// <summary>
/// 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
/// </summary>
public const int BaseTxVbytes = 252;

/// <summary>Each stage adds one P2TR output (~43 vB).</summary>
public const int PerStageVbytes = 43;

/// <summary>Each additional P2WPKH input adds ~68 vB.</summary>
public const int PerInputVbytes = 68;

/// <summary>
/// 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.
/// </summary>
public const long LightningClaimFeeHeadroomSats = 300;

/// <summary>Estimated virtual size of the investment transaction in vbytes.</summary>
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);
}

/// <summary>Estimated miner fee of the investment transaction in satoshis.</summary>
public static long EstimateInvestmentTxFee(int stageCount, long feeRateSatsPerVbyte, int inputCount = 1)
{
return feeRateSatsPerVbyte * EstimateInvestmentTxVbytes(stageCount, inputCount);
}

/// <summary>
/// Total on-chain amount required at the funding address:
/// investment amount + Angor fee + estimated investment tx miner fee.
/// </summary>
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);
}

/// <summary>
/// Total amount to request when funds arrive via a Lightning (Boltz reverse swap)
/// claim: same as <see cref="EstimateOnChainRequired"/> plus headroom for the
/// locally-built claim transaction fee that is deducted before funds land.
/// </summary>
public static long EstimateLightningRequired(
long investmentAmountSats,
int angorFeePercentage,
int stageCount,
long feeRateSatsPerVbyte)
{
return EstimateOnChainRequired(investmentAmountSats, angorFeePercentage, stageCount, feeRateSatsPerVbyte)
+ LightningClaimFeeHeadroomSats;
}
}
Loading
Loading