Skip to content

Commit 5dafa48

Browse files
Boltz-refactoring (block-core#695)
* Refactor Boltz swap implementation: remove unused code and add new storage service * removed obsolete service * Add BoltzConfiguration class for swap service settings * Refactor Boltz integration: move models and services to Angor.Shared.Integration.Lightning namespace * Refactor Boltz integration: move DTOs to Angor.Shared.Integration.Lightning and implement XunitLogger for test output * Update dependencies: add NBitcoin.Secp256k1 package and clean up unused using directives in test files * Add BoltzClaimService and BoltzMusig2 for cooperative claiming functionality * Add BoltzSwapStorageService for managing swaps in localStorage * Add AddressPollingService for monitoring incoming funds and refactor MempoolMonitoringService to use it * Enhance investment process feedback: add dynamic status messages during payment processing and streamline transaction submission * Refactor InvestInProject method to simplify navigation and remove wallet check * Refactor project stage percentage calculations to use decimal representation and ensure consistency across services * Remove unused MoreLinq dependency from BuildRecoveryTransaction.cs * Skip integration test requiring local boltz server * Update integration tests: enable FullReverseSwapFlow test and fix exception type in MempoolMonitoringServiceTests
1 parent 1cb3dc2 commit 5dafa48

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+3245
-1409
lines changed

src/Angor.Test/MempoolMonitoringServiceTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ public async Task MonitorAddressForFundsAsync_WhenCanceled_ThrowsOperationCancel
205205
});
206206

207207
// Act & Assert
208-
await Assert.ThrowsAsync<TaskCanceledException>(() =>
208+
await Assert.ThrowsAsync<OperationCanceledException>(() =>
209209
_sut.MonitorAddressForFundsAsync(
210210
address,
211211
requiredAmount,

src/Angor/Avalonia/Angor.Avalonia.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Angor.Test", "..\..\Angor.T
4242
EndProject
4343
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AngorApp.Tests", "AngorApp.Tests\AngorApp.Tests.csproj", "{E7997F04-DA79-4F76-A617-499EBC590B71}"
4444
EndProject
45+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Angor.Client", "..\Client\Angor.Client.csproj", "{0E91C19D-387F-4F60-87DE-54F0BB78814E}"
46+
EndProject
4547
Global
4648
GlobalSection(SolutionConfigurationPlatforms) = preSolution
4749
Debug|Any CPU = Debug|Any CPU
@@ -92,6 +94,10 @@ Global
9294
{E7997F04-DA79-4F76-A617-499EBC590B71}.Debug|Any CPU.Build.0 = Debug|Any CPU
9395
{E7997F04-DA79-4F76-A617-499EBC590B71}.Release|Any CPU.ActiveCfg = Release|Any CPU
9496
{E7997F04-DA79-4F76-A617-499EBC590B71}.Release|Any CPU.Build.0 = Release|Any CPU
97+
{0E91C19D-387F-4F60-87DE-54F0BB78814E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
98+
{0E91C19D-387F-4F60-87DE-54F0BB78814E}.Debug|Any CPU.Build.0 = Debug|Any CPU
99+
{0E91C19D-387F-4F60-87DE-54F0BB78814E}.Release|Any CPU.ActiveCfg = Release|Any CPU
100+
{0E91C19D-387F-4F60-87DE-54F0BB78814E}.Release|Any CPU.Build.0 = Release|Any CPU
95101
EndGlobalSection
96102
GlobalSection(SolutionProperties) = preSolution
97103
HideSolutionNode = FALSE
@@ -108,6 +114,7 @@ Global
108114
{0F80E799-F731-4CA1-8FBC-E6BF82465845} = {E68996BA-A784-4A8A-B0BB-AFD4C8F705AE}
109115
{5DCEA28A-CB30-4F4F-A922-61C14F10D486} = {43B28F35-B0E0-4F28-9F40-C2430B4C8426}
110116
{E7997F04-DA79-4F76-A617-499EBC590B71} = {43B28F35-B0E0-4F28-9F40-C2430B4C8426}
117+
{0E91C19D-387F-4F60-87DE-54F0BB78814E} = {4B0A97D7-BA72-4208-94B6-707935986EDC}
111118
EndGlobalSection
112119
GlobalSection(ExtensibilityGlobals) = postSolution
113120
SolutionGuid = {83CB65B8-011F-4ED7-BCD3-A6CFA935EF7E}
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
using Angor.Shared.Models;
2+
using Angor.Shared.Services;
3+
using Microsoft.Extensions.Logging.Abstractions;
4+
using Moq;
5+
using Xunit;
6+
7+
namespace Angor.Sdk.Tests.Funding.Investor.Operations;
8+
9+
/// <summary>
10+
/// Unit tests for AddressPollingService (IAddressPollingService).
11+
/// Tests the core polling loop behavior that was previously inside MempoolMonitoringService.MonitorAddressForFundsAsync.
12+
/// These tests lock down the behavior before the refactor so we can prove no regression.
13+
/// </summary>
14+
public class AddressPollingServiceTests
15+
{
16+
private readonly Mock<IIndexerService> _mockIndexerService;
17+
private readonly AddressPollingService _sut;
18+
19+
public AddressPollingServiceTests()
20+
{
21+
_mockIndexerService = new Mock<IIndexerService>();
22+
_sut = new AddressPollingService(
23+
_mockIndexerService.Object,
24+
new NullLogger<AddressPollingService>());
25+
}
26+
27+
[Fact]
28+
public async Task WaitForFunds_WhenSufficientFundsOnFirstPoll_ReturnsUtxosImmediately()
29+
{
30+
// Arrange
31+
var address = "tb1qtest_immediate";
32+
var requiredSats = 100_000L;
33+
var utxos = new List<UtxoData>
34+
{
35+
CreateUtxo(address, 150_000, "txid1", 0)
36+
};
37+
38+
_mockIndexerService
39+
.Setup(x => x.FetchUtxoAsync(address, It.IsAny<int>(), It.IsAny<int>()))
40+
.ReturnsAsync(utxos);
41+
42+
// Act
43+
var result = await _sut.WaitForFundsAsync(
44+
address, requiredSats,
45+
TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(50),
46+
CancellationToken.None);
47+
48+
// Assert
49+
Assert.NotNull(result);
50+
Assert.Single(result);
51+
Assert.Equal(150_000, result[0].value);
52+
53+
_mockIndexerService.Verify(
54+
x => x.FetchUtxoAsync(address, It.IsAny<int>(), It.IsAny<int>()),
55+
Times.Once);
56+
}
57+
58+
[Fact]
59+
public async Task WaitForFunds_WhenFundsArriveOnSecondPoll_RetriesAndReturns()
60+
{
61+
// Arrange
62+
var address = "tb1qtest_retry";
63+
var requiredSats = 100_000L;
64+
var utxos = new List<UtxoData>
65+
{
66+
CreateUtxo(address, 120_000, "txid1", 0)
67+
};
68+
69+
var callCount = 0;
70+
_mockIndexerService
71+
.Setup(x => x.FetchUtxoAsync(address, It.IsAny<int>(), It.IsAny<int>()))
72+
.ReturnsAsync(() =>
73+
{
74+
callCount++;
75+
// First call returns empty, second call returns funds
76+
return callCount >= 2 ? utxos : new List<UtxoData>();
77+
});
78+
79+
// Act
80+
var result = await _sut.WaitForFundsAsync(
81+
address, requiredSats,
82+
TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(50),
83+
CancellationToken.None);
84+
85+
// Assert
86+
Assert.NotNull(result);
87+
Assert.Single(result);
88+
Assert.Equal(120_000, result[0].value);
89+
Assert.True(callCount >= 2, "Should have polled at least twice");
90+
}
91+
92+
[Fact]
93+
public async Task WaitForFunds_WhenCancelled_ReturnsEmptyList()
94+
{
95+
// Arrange
96+
var address = "tb1qtest_cancel";
97+
var requiredSats = 100_000L;
98+
var cts = new CancellationTokenSource();
99+
100+
_mockIndexerService
101+
.Setup(x => x.FetchUtxoAsync(address, It.IsAny<int>(), It.IsAny<int>()))
102+
.ReturnsAsync(() =>
103+
{
104+
// Cancel after first poll returns no funds
105+
cts.Cancel();
106+
return new List<UtxoData>();
107+
});
108+
109+
// Act
110+
var result = await _sut.WaitForFundsAsync(
111+
address, requiredSats,
112+
TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(50),
113+
cts.Token);
114+
115+
// Assert
116+
Assert.NotNull(result);
117+
Assert.Empty(result);
118+
}
119+
120+
[Fact]
121+
public async Task WaitForFunds_WhenTimeout_ReturnsEmptyList()
122+
{
123+
// Arrange
124+
var address = "tb1qtest_timeout";
125+
var requiredSats = 100_000L;
126+
127+
_mockIndexerService
128+
.Setup(x => x.FetchUtxoAsync(address, It.IsAny<int>(), It.IsAny<int>()))
129+
.ReturnsAsync(new List<UtxoData>());
130+
131+
// Act - use a very short timeout
132+
var result = await _sut.WaitForFundsAsync(
133+
address, requiredSats,
134+
TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(30),
135+
CancellationToken.None);
136+
137+
// Assert
138+
Assert.NotNull(result);
139+
Assert.Empty(result);
140+
}
141+
142+
[Fact]
143+
public async Task WaitForFunds_WithMultipleUtxos_SumsCorrectly()
144+
{
145+
// Arrange
146+
var address = "tb1qtest_multi";
147+
var requiredSats = 200_000L;
148+
var utxos = new List<UtxoData>
149+
{
150+
CreateUtxo(address, 80_000, "txid1", 0),
151+
CreateUtxo(address, 70_000, "txid2", 0),
152+
CreateUtxo(address, 60_000, "txid3", 0)
153+
// Total: 210_000 >= 200_000
154+
};
155+
156+
_mockIndexerService
157+
.Setup(x => x.FetchUtxoAsync(address, It.IsAny<int>(), It.IsAny<int>()))
158+
.ReturnsAsync(utxos);
159+
160+
// Act
161+
var result = await _sut.WaitForFundsAsync(
162+
address, requiredSats,
163+
TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(50),
164+
CancellationToken.None);
165+
166+
// Assert
167+
Assert.NotNull(result);
168+
Assert.Equal(3, result.Count);
169+
Assert.Equal(210_000, result.Sum(u => u.value));
170+
}
171+
172+
[Fact]
173+
public async Task WaitForFunds_WithInsufficientMultipleUtxos_ContinuesPolling()
174+
{
175+
// Arrange
176+
var address = "tb1qtest_insufficient";
177+
var requiredSats = 200_000L;
178+
var insufficientUtxos = new List<UtxoData>
179+
{
180+
CreateUtxo(address, 50_000, "txid1", 0),
181+
CreateUtxo(address, 30_000, "txid2", 0)
182+
// Total: 80_000 < 200_000
183+
};
184+
var sufficientUtxos = new List<UtxoData>
185+
{
186+
CreateUtxo(address, 50_000, "txid1", 0),
187+
CreateUtxo(address, 30_000, "txid2", 0),
188+
CreateUtxo(address, 150_000, "txid3", 0)
189+
// Total: 230_000 >= 200_000
190+
};
191+
192+
var callCount = 0;
193+
_mockIndexerService
194+
.Setup(x => x.FetchUtxoAsync(address, It.IsAny<int>(), It.IsAny<int>()))
195+
.ReturnsAsync(() =>
196+
{
197+
callCount++;
198+
return callCount >= 3 ? sufficientUtxos : insufficientUtxos;
199+
});
200+
201+
// Act
202+
var result = await _sut.WaitForFundsAsync(
203+
address, requiredSats,
204+
TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(50),
205+
CancellationToken.None);
206+
207+
// Assert
208+
Assert.NotNull(result);
209+
Assert.Equal(3, result.Count);
210+
Assert.Equal(230_000, result.Sum(u => u.value));
211+
Assert.True(callCount >= 3);
212+
}
213+
214+
[Fact]
215+
public async Task WaitForFunds_OnlyCountsMempoolUtxos_IgnoresConfirmed()
216+
{
217+
// Arrange
218+
var address = "tb1qtest_mempool_only";
219+
var requiredSats = 100_000L;
220+
var utxos = new List<UtxoData>
221+
{
222+
CreateUtxo(address, 90_000, "txid1", 0, blockIndex: 500), // Confirmed - should be ignored
223+
CreateUtxo(address, 110_000, "txid2", 0, blockIndex: 0) // Mempool - should be counted
224+
};
225+
226+
_mockIndexerService
227+
.Setup(x => x.FetchUtxoAsync(address, It.IsAny<int>(), It.IsAny<int>()))
228+
.ReturnsAsync(utxos);
229+
230+
// Act
231+
var result = await _sut.WaitForFundsAsync(
232+
address, requiredSats,
233+
TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(50),
234+
CancellationToken.None);
235+
236+
// Assert
237+
Assert.NotNull(result);
238+
Assert.Single(result);
239+
Assert.Equal(110_000, result[0].value);
240+
Assert.Equal(0, result[0].blockIndex); // Only mempool UTXOs returned
241+
}
242+
243+
[Fact]
244+
public async Task WaitForFunds_WhenIndexerThrows_ContinuesPolling()
245+
{
246+
// Arrange
247+
var address = "tb1qtest_error_recovery";
248+
var requiredSats = 100_000L;
249+
var utxos = new List<UtxoData>
250+
{
251+
CreateUtxo(address, 150_000, "txid1", 0)
252+
};
253+
254+
var callCount = 0;
255+
_mockIndexerService
256+
.Setup(x => x.FetchUtxoAsync(address, It.IsAny<int>(), It.IsAny<int>()))
257+
.ReturnsAsync(() =>
258+
{
259+
callCount++;
260+
if (callCount == 1)
261+
throw new HttpRequestException("Network error");
262+
return utxos;
263+
});
264+
265+
// Act
266+
var result = await _sut.WaitForFundsAsync(
267+
address, requiredSats,
268+
TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(50),
269+
CancellationToken.None);
270+
271+
// Assert
272+
Assert.NotNull(result);
273+
Assert.Single(result);
274+
Assert.True(callCount >= 2, "Should have retried after error");
275+
}
276+
277+
[Fact]
278+
public async Task WaitForFunds_WhenIndexerReturnsNull_ContinuesPolling()
279+
{
280+
// Arrange
281+
var address = "tb1qtest_null";
282+
var requiredSats = 100_000L;
283+
var utxos = new List<UtxoData>
284+
{
285+
CreateUtxo(address, 150_000, "txid1", 0)
286+
};
287+
288+
var callCount = 0;
289+
_mockIndexerService
290+
.Setup(x => x.FetchUtxoAsync(address, It.IsAny<int>(), It.IsAny<int>()))
291+
.ReturnsAsync(() =>
292+
{
293+
callCount++;
294+
if (callCount == 1)
295+
return null;
296+
return utxos;
297+
});
298+
299+
// Act
300+
var result = await _sut.WaitForFundsAsync(
301+
address, requiredSats,
302+
TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(50),
303+
CancellationToken.None);
304+
305+
// Assert
306+
Assert.NotNull(result);
307+
Assert.Single(result);
308+
Assert.True(callCount >= 2);
309+
}
310+
311+
[Fact]
312+
public async Task WaitForFunds_ExactAmountMatch_ReturnsUtxos()
313+
{
314+
// Arrange
315+
var address = "tb1qtest_exact";
316+
var requiredSats = 100_000L;
317+
var utxos = new List<UtxoData>
318+
{
319+
CreateUtxo(address, 100_000, "txid1", 0) // Exactly the required amount
320+
};
321+
322+
_mockIndexerService
323+
.Setup(x => x.FetchUtxoAsync(address, It.IsAny<int>(), It.IsAny<int>()))
324+
.ReturnsAsync(utxos);
325+
326+
// Act
327+
var result = await _sut.WaitForFundsAsync(
328+
address, requiredSats,
329+
TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(50),
330+
CancellationToken.None);
331+
332+
// Assert
333+
Assert.NotNull(result);
334+
Assert.Single(result);
335+
Assert.Equal(100_000, result[0].value);
336+
}
337+
338+
#region Helper Methods
339+
340+
private static UtxoData CreateUtxo(string address, long value, string txId, int outputIndex, int blockIndex = 0)
341+
{
342+
return new UtxoData
343+
{
344+
address = address,
345+
value = value,
346+
outpoint = new Outpoint(txId, outputIndex),
347+
scriptHex = "",
348+
blockIndex = blockIndex
349+
};
350+
}
351+
352+
#endregion
353+
}
354+

0 commit comments

Comments
 (0)