Skip to content

Commit 2f12953

Browse files
authored
Fix the release funds to investors flow (block-core#674)
* rename wallet form default to bitcoin wallet * add a prefill button for debug mode * Add the ability to claim released funds by investor * fix the fee issue
1 parent c58658b commit 2f12953

32 files changed

+596
-165
lines changed

src/Angor.Test/MempoolMonitoringServiceTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ public async Task MonitorAddressForFundsAsync_WhenPartialFunds_ContinuesMonitori
188188
}
189189

190190
[Fact]
191-
public async Task MonitorAddressForFundsAsync_WhenCancelled_ThrowsOperationCanceledException()
191+
public async Task MonitorAddressForFundsAsync_WhenCanceled_ThrowsOperationCanceledException()
192192
{
193193
// Arrange
194194
var address = "bc1qtest123";

src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/InvestmentAppServiceTests.cs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Angor.Sdk.Common;
2+
using Angor.Sdk.Funding.Investor;
23
using Angor.Sdk.Funding.Investor.Domain;
34
using Angor.Sdk.Funding.Investor.Operations;
45
using Angor.Sdk.Funding.Projects;
@@ -30,6 +31,7 @@ public class InvestmentAppServiceTests : IClassFixture<TestNetworkFixture>
3031
private readonly Mock<IInvestmentHandshakeService> _mockInvestmentHandshakeService;
3132
private readonly Mock<ITransactionService> _mockTransactionService;
3233
private readonly Mock<IProjectInvestmentsService> _mockProjectInvestmentsService;
34+
private readonly Mock<IInvestmentAppService> _mockInvestmentAppService;
3335

3436
public InvestmentAppServiceTests(TestNetworkFixture fixture)
3537
{
@@ -40,6 +42,7 @@ public InvestmentAppServiceTests(TestNetworkFixture fixture)
4042
_mockInvestmentHandshakeService = new Mock<IInvestmentHandshakeService>();
4143
_mockTransactionService = new Mock<ITransactionService>();
4244
_mockProjectInvestmentsService = new Mock<IProjectInvestmentsService>();
45+
_mockInvestmentAppService = new Mock<IInvestmentAppService>();
4346
}
4447

4548
#region GetInvestmentsHandler Tests
@@ -238,7 +241,8 @@ public async Task GetRecoveryStatusHandler_WhenProjectNotFound_ReturnsFailure()
238241
_fixture.NetworkConfiguration,
239242
_fixture.InvestorTransactionActions,
240243
_mockProjectInvestmentsService.Object,
241-
_mockTransactionService.Object);
244+
_mockTransactionService.Object,
245+
_mockInvestmentAppService.Object);
242246

243247
var request = new GetRecoveryStatus.GetRecoveryStatusRequest(walletId, projectId);
244248

@@ -272,7 +276,8 @@ public async Task GetRecoveryStatusHandler_WhenPortfolioFails_ReturnsFailure()
272276
_fixture.NetworkConfiguration,
273277
_fixture.InvestorTransactionActions,
274278
_mockProjectInvestmentsService.Object,
275-
_mockTransactionService.Object);
279+
_mockTransactionService.Object,
280+
_mockInvestmentAppService.Object);
276281

277282
var request = new GetRecoveryStatus.GetRecoveryStatusRequest(walletId, project.Id);
278283

@@ -307,7 +312,8 @@ public async Task GetRecoveryStatusHandler_WhenNoInvestmentsForWallet_ReturnsFai
307312
_fixture.NetworkConfiguration,
308313
_fixture.InvestorTransactionActions,
309314
_mockProjectInvestmentsService.Object,
310-
_mockTransactionService.Object);
315+
_mockTransactionService.Object,
316+
_mockInvestmentAppService.Object);
311317

312318
var request = new GetRecoveryStatus.GetRecoveryStatusRequest(walletId, project.Id);
313319

@@ -348,7 +354,8 @@ public async Task GetRecoveryStatusHandler_WhenNoInvestmentForProject_ReturnsFai
348354
_fixture.NetworkConfiguration,
349355
_fixture.InvestorTransactionActions,
350356
_mockProjectInvestmentsService.Object,
351-
_mockTransactionService.Object);
357+
_mockTransactionService.Object,
358+
_mockInvestmentAppService.Object);
352359

353360
var request = new GetRecoveryStatus.GetRecoveryStatusRequest(walletId, project.Id);
354361

@@ -378,7 +385,8 @@ public async Task GetRecoveryStatusHandler_CallsProjectServiceWithCorrectId()
378385
_fixture.NetworkConfiguration,
379386
_fixture.InvestorTransactionActions,
380387
_mockProjectInvestmentsService.Object,
381-
_mockTransactionService.Object);
388+
_mockTransactionService.Object,
389+
_mockInvestmentAppService.Object);
382390

383391
var request = new GetRecoveryStatus.GetRecoveryStatusRequest(walletId, projectId);
384392

src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Dtos/InvestorProjectRecoveryDto.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ public class InvestorProjectRecoveryDto
1515
public bool EndOfProject { get; set; }
1616
public bool IsAboveThreshold { get; set; }
1717

18+
/// <summary>
19+
/// True when the founder has sent release signatures via Nostr,
20+
/// meaning the investor can claim their funds back without penalty.
21+
/// </summary>
22+
public bool HasReleaseSignatures { get; set; }
23+
1824
public string? ExplorerLink { get; set; }
1925
public string TransactionId { get; set; }
2026

src/Angor/Avalonia/Angor.Sdk/Funding/Investor/IInvestmentAppService.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,20 @@ public interface IInvestmentAppService
1515
// Methods for Investor - Manage funds
1616
Task<Result<GetRecoveryStatus.GetRecoveryStatusResponse>> GetRecoveryStatus(GetRecoveryStatus.GetRecoveryStatusRequest request);
1717
Task<Result<BuildRecoveryTransaction.BuildRecoveryTransactionResponse>> BuildRecoveryTransaction(BuildRecoveryTransaction.BuildRecoveryTransactionRequest request);
18-
Task<Result<BuildReleaseTransaction.BuildReleaseTransactionResponse>> BuildReleaseTransaction(BuildReleaseTransaction.BuildReleaseTransactionRequest request);
18+
Task<Result<BuildUnfundedReleaseTransaction.BuildUnfundedReleaseTransactionResponse>> BuildUnfundedReleaseTransaction(BuildUnfundedReleaseTransaction.BuildUnfundedReleaseTransactionRequest request);
19+
Task<Result<BuildPenaltyReleaseTransaction.BuildPenaltyReleaseTransactionResponse>> BuildPenaltyReleaseTransaction(BuildPenaltyReleaseTransaction.BuildPenaltyReleaseTransactionRequest request);
1920
Task<Result<BuildEndOfProjectClaim.BuildEndOfProjectClaimResponse>> BuildEndOfProjectClaim(BuildEndOfProjectClaim.BuildEndOfProjectClaimRequest request);
2021

2122
Task<Result<PublishAndStoreInvestorTransaction.PublishAndStoreInvestorTransactionResponse>> SubmitTransactionFromDraft(PublishAndStoreInvestorTransaction.PublishAndStoreInvestorTransactionRequest request);
2223

2324
Task<Result<CheckPenaltyThreshold.CheckPenaltyThresholdResponse>> IsInvestmentAbovePenaltyThreshold(CheckPenaltyThreshold.CheckPenaltyThresholdRequest request);
2425

26+
/// <summary>
27+
/// Checks whether the founder has sent release signatures for the investor's investment in a project.
28+
/// This is a lightweight check (no transaction building) so the UI can show release availability.
29+
/// </summary>
30+
Task<Result<CheckForReleaseSignatures.CheckForReleaseSignaturesResponse>> CheckForReleaseSignatures(CheckForReleaseSignatures.CheckForReleaseSignaturesRequest request);
31+
2532
// Methods for getting investor keys
2633
Task<Result<GetInvestorNsec.GetInvestorNsecResponse>> GetInvestorNsec(GetInvestorNsec.GetInvestorNsecRequest request);
2734

src/Angor/Avalonia/Angor.Sdk/Funding/Investor/InvestmentAppService.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ public class InvestmentAppService(IMediator mediator) : IInvestmentAppService
2727
public Task<Result<CheckPenaltyThreshold.CheckPenaltyThresholdResponse>> IsInvestmentAbovePenaltyThreshold(CheckPenaltyThreshold.CheckPenaltyThresholdRequest request)
2828
=> mediator.Send(request);
2929

30+
public Task<Result<CheckForReleaseSignatures.CheckForReleaseSignaturesResponse>> CheckForReleaseSignatures(CheckForReleaseSignatures.CheckForReleaseSignaturesRequest request)
31+
=> mediator.Send(request);
32+
3033
public Task<Result<GetInvestorNsec.GetInvestorNsecResponse>> GetInvestorNsec(GetInvestorNsec.GetInvestorNsecRequest request)
3134
=> mediator.Send(request);
3235

@@ -38,7 +41,10 @@ public class InvestmentAppService(IMediator mediator) : IInvestmentAppService
3841
public Task<Result<BuildRecoveryTransaction.BuildRecoveryTransactionResponse>> BuildRecoveryTransaction(BuildRecoveryTransaction.BuildRecoveryTransactionRequest request)
3942
=> mediator.Send(request);
4043

41-
public Task<Result<BuildReleaseTransaction.BuildReleaseTransactionResponse>> BuildReleaseTransaction(BuildReleaseTransaction.BuildReleaseTransactionRequest request)
44+
public Task<Result<BuildUnfundedReleaseTransaction.BuildUnfundedReleaseTransactionResponse>> BuildUnfundedReleaseTransaction(BuildUnfundedReleaseTransaction.BuildUnfundedReleaseTransactionRequest request)
45+
=> mediator.Send(request);
46+
47+
public Task<Result<BuildPenaltyReleaseTransaction.BuildPenaltyReleaseTransactionResponse>> BuildPenaltyReleaseTransaction(BuildPenaltyReleaseTransaction.BuildPenaltyReleaseTransactionRequest request)
4248
=> mediator.Send(request);
4349

4450
public Task<Result<BuildEndOfProjectClaim.BuildEndOfProjectClaimResponse>> BuildEndOfProjectClaim(BuildEndOfProjectClaim.BuildEndOfProjectClaimRequest request)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using Angor.Sdk.Common;
2+
using Angor.Sdk.Funding.Investor.Domain;
3+
using Angor.Sdk.Funding.Projects.Domain;
4+
using Angor.Sdk.Funding.Services;
5+
using Angor.Sdk.Funding.Shared;
6+
using Angor.Sdk.Funding.Shared.TransactionDrafts;
7+
using Angor.Shared;
8+
using Angor.Shared.Models;
9+
using Angor.Shared.Protocol;
10+
using Angor.Shared.Services;
11+
using Blockcore.NBitcoin;
12+
using Blockcore.NBitcoin.DataEncoders;
13+
using CSharpFunctionalExtensions;
14+
using MediatR;
15+
using Angor.Sdk.Funding.Projects;
16+
17+
namespace Angor.Sdk.Funding.Investor.Operations;
18+
19+
/// <summary>
20+
/// Builds a transaction to release funds from penalty timelock.
21+
/// This is the second step after recovery — once the penalty period has expired,
22+
/// the investor can spend the penalty-locked outputs back to themselves.
23+
/// Uses <see cref="IInvestorTransactionActions.BuildAndSignRecoverReleaseFundsTransaction"/>.
24+
/// </summary>
25+
public static class BuildPenaltyReleaseTransaction
26+
{
27+
public record BuildPenaltyReleaseTransactionRequest(WalletId WalletId, ProjectId ProjectId, DomainFeerate SelectedFeeRate) : IRequest<Result<BuildPenaltyReleaseTransactionResponse>>;
28+
29+
public record BuildPenaltyReleaseTransactionResponse(ReleaseTransactionDraft TransactionDraft);
30+
31+
public class BuildPenaltyReleaseTransactionHandler(
32+
ISeedwordsProvider provider,
33+
IDerivationOperations derivationOperations,
34+
IProjectService projectService,
35+
IPortfolioService investmentService,
36+
INetworkConfiguration networkConfiguration,
37+
IInvestorTransactionActions investorTransactionActions,
38+
ITransactionService transactionService,
39+
IWalletAccountBalanceService walletAccountBalanceService
40+
) : IRequestHandler<BuildPenaltyReleaseTransactionRequest, Result<BuildPenaltyReleaseTransactionResponse>>
41+
{
42+
public async Task<Result<BuildPenaltyReleaseTransactionResponse>> Handle(BuildPenaltyReleaseTransactionRequest request, CancellationToken cancellationToken)
43+
{
44+
var project = await projectService.GetAsync(request.ProjectId);
45+
if (project.IsFailure)
46+
return Result.Failure<BuildPenaltyReleaseTransactionResponse>(project.Error);
47+
48+
var investments = await investmentService.GetByWalletId(request.WalletId.Value);
49+
if (investments.IsFailure)
50+
return Result.Failure<BuildPenaltyReleaseTransactionResponse>(investments.Error);
51+
52+
var investment = investments.Value.ProjectIdentifiers
53+
.FirstOrDefault(p => p.ProjectIdentifier == request.ProjectId.Value);
54+
if (investment is null)
55+
return Result.Failure<BuildPenaltyReleaseTransactionResponse>("No investment found for this project");
56+
57+
if (string.IsNullOrEmpty(investment.RecoveryTransactionId))
58+
return Result.Failure<BuildPenaltyReleaseTransactionResponse>("No recovery transaction found — recovery must be done before releasing from penalty");
59+
60+
if (!string.IsNullOrEmpty(investment.RecoveryReleaseTransactionId))
61+
return Result.Failure<BuildPenaltyReleaseTransactionResponse>("Penalty release transaction has already been published");
62+
63+
var words = await provider.GetSensitiveData(request.WalletId.Value);
64+
if (words.IsFailure)
65+
return Result.Failure<BuildPenaltyReleaseTransactionResponse>(words.Error);
66+
67+
// Get account info from database
68+
var accountBalanceResult = await walletAccountBalanceService.GetAccountBalanceInfoAsync(request.WalletId);
69+
if (accountBalanceResult.IsFailure)
70+
return Result.Failure<BuildPenaltyReleaseTransactionResponse>(accountBalanceResult.Error);
71+
72+
var accountInfo = accountBalanceResult.Value.AccountInfo;
73+
74+
var changeAddress = accountInfo.GetNextChangeReceiveAddress();
75+
if (changeAddress == null)
76+
return Result.Failure<BuildPenaltyReleaseTransactionResponse>("Could not get a change address");
77+
78+
var investorPrivateKey = derivationOperations.DeriveInvestorPrivateKey(words.Value.ToWalletWords(), project.Value.FounderKey);
79+
80+
// Get the investment transaction
81+
var investmentTrxHex = investment.InvestmentTransactionHex;
82+
if (string.IsNullOrEmpty(investmentTrxHex))
83+
return Result.Failure<BuildPenaltyReleaseTransactionResponse>("Investment transaction hex not found");
84+
85+
var investmentTransaction = networkConfiguration.GetNetwork().CreateTransaction(investmentTrxHex);
86+
87+
// Get the recovery transaction from the indexer
88+
var recoveryTrxHex = await transactionService.GetTransactionHexByIdAsync(investment.RecoveryTransactionId);
89+
if (string.IsNullOrEmpty(recoveryTrxHex))
90+
return Result.Failure<BuildPenaltyReleaseTransactionResponse>("Recovery transaction not found on the network");
91+
92+
var recoveryTransaction = networkConfiguration.GetNetwork().CreateTransaction(recoveryTrxHex);
93+
94+
// Build and sign the penalty release transaction
95+
var feeEstimation = new FeeEstimation { FeeRate = request.SelectedFeeRate.SatsPerKilobyte };
96+
97+
var releaseTransactionInfo = investorTransactionActions.BuildAndSignRecoverReleaseFundsTransaction(
98+
project.Value.ToProjectInfo(),
99+
investmentTransaction,
100+
recoveryTransaction,
101+
changeAddress,
102+
feeEstimation,
103+
Encoders.Hex.EncodeData(investorPrivateKey.ToBytes()));
104+
105+
return Result.Success(new BuildPenaltyReleaseTransactionResponse(new ReleaseTransactionDraft
106+
{
107+
SignedTxHex = releaseTransactionInfo.Transaction.ToHex(),
108+
TransactionFee = new Amount(releaseTransactionInfo.TransactionFee),
109+
TransactionId = releaseTransactionInfo.Transaction.GetHash().ToString()
110+
}));
111+
}
112+
}
113+
}

0 commit comments

Comments
 (0)