diff --git a/src/Angor.Test/AddInputsFromAddressAndSignTransactionTests.cs b/src/Angor.Test/AddInputsFromAddressAndSignTransactionTests.cs index 27fbffda6..f8142960a 100644 --- a/src/Angor.Test/AddInputsFromAddressAndSignTransactionTests.cs +++ b/src/Angor.Test/AddInputsFromAddressAndSignTransactionTests.cs @@ -3,7 +3,7 @@ using Angor.Shared.Protocol; using Angor.Shared.Protocol.Scripts; using Angor.Shared.Protocol.TransactionBuilders; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Angor.Test.Protocol; using Blockcore.Consensus.ScriptInfo; using Blockcore.Consensus.TransactionInfo; diff --git a/src/Angor.Test/MempoolMonitoringServiceTests.cs b/src/Angor.Test/MempoolMonitoringServiceTests.cs index 36ca26e4c..4bf2e0e50 100644 --- a/src/Angor.Test/MempoolMonitoringServiceTests.cs +++ b/src/Angor.Test/MempoolMonitoringServiceTests.cs @@ -1,5 +1,5 @@ using Angor.Shared.Models; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Blockcore.NBitcoin; using Blockcore.NBitcoin.DataEncoders; using Microsoft.Extensions.Logging.Abstractions; diff --git a/src/Angor.Test/PsbtOperationsTests.cs b/src/Angor.Test/PsbtOperationsTests.cs index 0409380a1..0a33ccbe5 100644 --- a/src/Angor.Test/PsbtOperationsTests.cs +++ b/src/Angor.Test/PsbtOperationsTests.cs @@ -7,7 +7,6 @@ using Blockcore.NBitcoin.DataEncoders; using Microsoft.Extensions.Logging.Abstractions; using Moq; -using Angor.Shared.Services; using Angor.Test.Protocol; using Money = Blockcore.NBitcoin.Money; using uint256 = Blockcore.NBitcoin.uint256; @@ -15,6 +14,7 @@ using Blockcore.Networks; using Microsoft.Extensions.Logging; using Blockcore.NBitcoin.BIP32; +using Angor.Shared.Services.Indexer; namespace Angor.Test; diff --git a/src/Angor.Test/Services/Electrum/ElectrumAngorIndexerServiceTests.cs b/src/Angor.Test/Services/Electrum/ElectrumAngorIndexerServiceTests.cs new file mode 100644 index 000000000..b4843939a --- /dev/null +++ b/src/Angor.Test/Services/Electrum/ElectrumAngorIndexerServiceTests.cs @@ -0,0 +1,244 @@ +using Angor.Shared; +using Angor.Shared.Models; +using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; +using Angor.Shared.Services.Indexer.Electrum; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using Xunit.Abstractions; + +namespace Angor.Test.Services.Electrum; + +public class ElectrumAngorIndexerServiceTests : IAsyncLifetime +{ + private readonly ITestOutputHelper _output; + private ElectrumClientPool _clientPool = null!; + private ElectrumAngorIndexerService _service = null!; + private readonly Mock _networkConfig; + private readonly Mock _derivationOperations; + + private const string TestServerHost = "electrum.blockstream.info"; + private const int TestServerPort = 50002; + private const bool UseSsl = true; + + private const string TestProjectId = "02a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + private const string TestProjectAddress = "tb1qexampleaddresshere"; + private const string TestInvestorPubKey = "03a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + + public ElectrumAngorIndexerServiceTests(ITestOutputHelper output) + { + _output = output; + _networkConfig = new Mock(); + _derivationOperations = new Mock(); + + var network = Angor.Shared.Networks.Networks.Bitcoin.Testnet(); + _networkConfig.Setup(x => x.GetNetwork()).Returns(network); + + _derivationOperations + .Setup(x => x.ConvertAngorKeyToBitcoinAddress(It.IsAny())) + .Returns(TestProjectAddress); + } + + public async Task InitializeAsync() + { + var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + var poolLogger = loggerFactory.CreateLogger(); + var serviceLogger = loggerFactory.CreateLogger(); + var mapperLogger = loggerFactory.CreateLogger(); + + var serverConfig = new ElectrumServerConfig + { + Host = TestServerHost, + Port = TestServerPort, + UseSsl = UseSsl, + Timeout = TimeSpan.FromSeconds(30) + }; + + _clientPool = new ElectrumClientPool(poolLogger, loggerFactory, new[] { serverConfig }); + + var mappers = new MempoolIndexerMappers(mapperLogger); + + _service = new ElectrumAngorIndexerService( + serviceLogger, + _networkConfig.Object, + _derivationOperations.Object, + _clientPool, + mappers); + + _output.WriteLine($"Initialized with server: {TestServerHost}:{TestServerPort}"); + } + + public async Task DisposeAsync() + { + await _clientPool.DisconnectAllAsync(); + _clientPool.Dispose(); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task GetProjectsAsync_ReturnsEmptyList_NotSupportedViaElectrum() + { + var result = await _service.GetProjectsAsync(null, 10); + + _output.WriteLine("GetProjectsAsync is not supported via Electrum protocol"); + _output.WriteLine($"Result count: {result.Count}"); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server and valid project")] + public async Task GetProjectByIdAsync_WithValidProjectId_ReturnsProjectData() + { + _derivationOperations + .Setup(x => x.ConvertAngorKeyToBitcoinAddress(TestProjectId)) + .Returns(TestProjectAddress); + + var result = await _service.GetProjectByIdAsync(TestProjectId); + + _output.WriteLine($"Project data for {TestProjectId}:"); + if (result != null) + { + _output.WriteLine($" Project Identifier: {result.ProjectIdentifier}"); + _output.WriteLine($" Founder Key: {result.FounderKey}"); + _output.WriteLine($" Created On Block: {result.CreatedOnBlock}"); + _output.WriteLine($" Nostr Event ID: {result.NostrEventId}"); + _output.WriteLine($" Transaction ID: {result.TrxId}"); + _output.WriteLine($" Total Investments Count: {result.TotalInvestmentsCount}"); + } + else + { + _output.WriteLine(" No project data found (this may be expected if project doesn't exist)"); + } + + Assert.True(result == null || !string.IsNullOrEmpty(result.ProjectIdentifier)); + } + + [Fact(Skip = "Manual test - requires active Electrum server and valid project")] + public async Task GetProjectStatsAsync_WithValidProjectId_ReturnsStats() + { + _derivationOperations + .Setup(x => x.ConvertAngorKeyToBitcoinAddress(TestProjectId)) + .Returns(TestProjectAddress); + + var result = await _service.GetProjectStatsAsync(TestProjectId); + + _output.WriteLine($"Project stats for {result.projectId}:"); + if (result.stats != null) + { + _output.WriteLine($" Investor Count: {result.stats.InvestorCount}"); + _output.WriteLine($" Amount Invested: {result.stats.AmountInvested} sats"); + _output.WriteLine($" Amount In Penalties: {result.stats.AmountInPenalties} sats"); + _output.WriteLine($" Count In Penalties: {result.stats.CountInPenalties}"); + } + else + { + _output.WriteLine(" No stats found (this may be expected if project doesn't exist)"); + } + + Assert.Equal(TestProjectId, result.projectId); + } + + [Fact(Skip = "Manual test - requires active Electrum server and valid project")] + public async Task GetInvestmentsAsync_WithValidProjectId_ReturnsInvestments() + { + _derivationOperations + .Setup(x => x.ConvertAngorKeyToBitcoinAddress(TestProjectId)) + .Returns(TestProjectAddress); + + var result = await _service.GetInvestmentsAsync(TestProjectId); + + _output.WriteLine($"Investments for project {TestProjectId}:"); + _output.WriteLine($"Total investments: {result.Count}"); + + foreach (var investment in result.Take(5)) + { + _output.WriteLine($" Transaction ID: {investment.TransactionId}"); + _output.WriteLine($" Investor Public Key: {investment.InvestorPublicKey}"); + _output.WriteLine($" Hash of Secret: {investment.HashOfSecret}"); + _output.WriteLine(" ---"); + } + + Assert.NotNull(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server and valid project/investor")] + public async Task GetInvestmentAsync_WithValidProjectAndInvestor_ReturnsInvestment() + { + _derivationOperations + .Setup(x => x.ConvertAngorKeyToBitcoinAddress(TestProjectId)) + .Returns(TestProjectAddress); + + var result = await _service.GetInvestmentAsync(TestProjectId, TestInvestorPubKey); + + _output.WriteLine($"Investment for project {TestProjectId} by investor {TestInvestorPubKey}:"); + if (result != null) + { + _output.WriteLine($" Transaction ID: {result.TransactionId}"); + _output.WriteLine($" Investor Public Key: {result.InvestorPublicKey}"); + _output.WriteLine($" Hash of Secret: {result.HashOfSecret}"); + } + else + { + _output.WriteLine(" No investment found"); + } + + Assert.True(result == null || !string.IsNullOrEmpty(result.TransactionId)); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task GetProjectByIdAsync_WithEmptyProjectId_ReturnsNull() + { + var result = await _service.GetProjectByIdAsync(""); + + _output.WriteLine("Empty project ID should return null"); + Assert.Null(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task GetProjectByIdAsync_WithShortProjectId_ReturnsNull() + { + var result = await _service.GetProjectByIdAsync("a"); + + _output.WriteLine("Short project ID (length <= 1) should return null"); + Assert.Null(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task GetProjectStatsAsync_WithEmptyProjectId_ReturnsNullStats() + { + var result = await _service.GetProjectStatsAsync(""); + + _output.WriteLine("Empty project ID should return null stats"); + Assert.Equal("", result.projectId); + Assert.Null(result.stats); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task GetInvestmentsAsync_WithEmptyProjectId_ReturnsEmptyList() + { + var result = await _service.GetInvestmentsAsync(""); + + _output.WriteLine("Empty project ID should return empty list"); + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task GetInvestmentAsync_WithEmptyProjectId_ReturnsNull() + { + var result = await _service.GetInvestmentAsync("", TestInvestorPubKey); + + _output.WriteLine("Empty project ID should return null"); + Assert.Null(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task GetInvestmentAsync_WithEmptyInvestorPubKey_ReturnsNull() + { + var result = await _service.GetInvestmentAsync(TestProjectId, ""); + + _output.WriteLine("Empty investor public key should return null"); + Assert.Null(result); + } +} diff --git a/src/Angor.Test/Services/Electrum/ElectrumClientTests.cs b/src/Angor.Test/Services/Electrum/ElectrumClientTests.cs new file mode 100644 index 000000000..3c3bb9bc1 --- /dev/null +++ b/src/Angor.Test/Services/Electrum/ElectrumClientTests.cs @@ -0,0 +1,323 @@ +using Angor.Shared.Services.Indexer.Electrum; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Angor.Test.Services.Electrum; + +/// +/// Manual integration tests for ElectrumClient. +/// These tests connect to real Electrum servers and should be run manually. +/// +public class ElectrumClientTests : IAsyncLifetime +{ + private readonly ITestOutputHelper _output; + private ElectrumClient _client = null!; + private readonly ILogger _logger; + + // Test configuration + private const string TestServerHost = "electrum.blockstream.info"; + private const int TestServerPort = 50002; + private const bool UseSsl = true; + + public ElectrumClientTests(ITestOutputHelper output) + { + _output = output; + var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + _logger = loggerFactory.CreateLogger(); + } + + public Task InitializeAsync() + { + var config = new ElectrumServerConfig + { + Host = TestServerHost, + Port = TestServerPort, + UseSsl = UseSsl, + AllowSelfSignedCertificates = true, + Timeout = TimeSpan.FromSeconds(30) + }; + + _client = new ElectrumClient(_logger, config); + _output.WriteLine($"Created client for {TestServerHost}:{TestServerPort}"); + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _client.DisconnectAsync(); + _client.Dispose(); + } + + [Fact]//(Skip = "Manual test - requires active Electrum server")] + public async Task ConnectAsync_ToValidServer_Connects() + { + // Act + await _client.ConnectAsync(); + + // Assert + _output.WriteLine($"Connected: {_client.IsConnected}"); + _output.WriteLine($"Server URL: {_client.ServerUrl}"); + + Assert.True(_client.IsConnected); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task ServerVersion_ReturnsVersionInfo() + { + // Arrange + await _client.ConnectAsync(); + + // Act + var result = await _client.SendRequestAsync( + "server.version", + new object[] { "AngorTest/1.0", "1.4" }); + + // Assert + _output.WriteLine("Server version info:"); + _output.WriteLine($" Server: {result[0]}"); + _output.WriteLine($" Protocol: {result[1]}"); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task ServerBanner_ReturnsBanner() + { + // Arrange + await _client.ConnectAsync(); + + // Act + var result = await _client.SendRequestAsync("server.banner"); + + // Assert + _output.WriteLine("Server banner:"); + _output.WriteLine(result); + + Assert.NotNull(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task ServerFeatures_ReturnsFeatures() + { + // Arrange + await _client.ConnectAsync(); + + // Act + var result = await _client.SendRequestAsync("server.features"); + + // Assert + _output.WriteLine("Server features:"); + _output.WriteLine(result.ToString()); + + Assert.NotNull(result.ToString()); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task BlockchainHeadersSubscribe_ReturnsCurrentHeader() + { + // Arrange + await _client.ConnectAsync(); + + // Act + var result = await _client.SendRequestAsync("blockchain.headers.subscribe"); + + // Assert + _output.WriteLine("Current block header:"); + _output.WriteLine(result.ToString()); + + Assert.NotNull(result.ToString()); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task BlockchainBlockHeader_ReturnsHeader() + { + // Arrange + await _client.ConnectAsync(); + const int blockHeight = 0; // Genesis block + + // Act + var result = await _client.SendRequestAsync( + "blockchain.block.header", + new object[] { blockHeight }); + + // Assert + _output.WriteLine($"Block header at height {blockHeight}:"); + _output.WriteLine($" Length: {result.Length} chars"); + _output.WriteLine($" Header: {result.Substring(0, Math.Min(80, result.Length))}..."); + + Assert.NotNull(result); + Assert.NotEmpty(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task BlockchainEstimateFee_ReturnsFeeRate() + { + // Arrange + await _client.ConnectAsync(); + const int targetBlocks = 6; + + // Act + var result = await _client.SendRequestAsync( + "blockchain.estimatefee", + new object[] { targetBlocks }); + + // Assert + _output.WriteLine($"Fee estimate for {targetBlocks} blocks:"); + _output.WriteLine($" BTC/kB: {result}"); + _output.WriteLine($" sat/kB: {(long)(result * 100_000_000)}"); + + // Fee can be -1 if not enough data + Assert.True(result != 0); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task BlockchainScripthashGetBalance_ReturnsBalance() + { + // Arrange + await _client.ConnectAsync(); + + // Known testnet address scripthash (you can calculate this using ElectrumScriptHashUtility) + // This is a placeholder - replace with actual scripthash + const string scriptHash = "8b01df4e368ea28f8dc0423bcf7a4923e3a12d307c875e47a0cfbf90b5c39161"; + + // Act + var result = await _client.SendRequestAsync( + "blockchain.scripthash.get_balance", + new object[] { scriptHash }); + + // Assert + _output.WriteLine($"Balance for scripthash {scriptHash}:"); + _output.WriteLine(result.ToString()); + + Assert.NotNull(result.ToString()); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task BlockchainScripthashGetHistory_ReturnsHistory() + { + // Arrange + await _client.ConnectAsync(); + const string scriptHash = "8b01df4e368ea28f8dc0423bcf7a4923e3a12d307c875e47a0cfbf90b5c39161"; + + // Act + var result = await _client.SendRequestAsync( + "blockchain.scripthash.get_history", + new object[] { scriptHash }); + + // Assert + _output.WriteLine($"History for scripthash {scriptHash}:"); + _output.WriteLine(result.ToString()); + + Assert.NotNull(result.ToString()); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task BlockchainScripthashListUnspent_ReturnsUtxos() + { + // Arrange + await _client.ConnectAsync(); + const string scriptHash = "8b01df4e368ea28f8dc0423bcf7a4923e3a12d307c875e47a0cfbf90b5c39161"; + + // Act + var result = await _client.SendRequestAsync( + "blockchain.scripthash.listunspent", + new object[] { scriptHash }); + + // Assert + _output.WriteLine($"UTXOs for scripthash {scriptHash}:"); + _output.WriteLine(result.ToString()); + + Assert.NotNull(result.ToString()); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task BlockchainTransactionGet_ReturnsTransaction() + { + // Arrange + await _client.ConnectAsync(); + + // Bitcoin genesis coinbase transaction + const string txId = "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"; + + // Act - get raw hex + var hexResult = await _client.SendRequestAsync( + "blockchain.transaction.get", + new object[] { txId, false }); + + // Assert + _output.WriteLine($"Transaction hex for {txId}:"); + _output.WriteLine($" Length: {hexResult.Length}"); + _output.WriteLine($" Hex: {hexResult.Substring(0, Math.Min(100, hexResult.Length))}..."); + + Assert.NotNull(hexResult); + Assert.NotEmpty(hexResult); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task BlockchainTransactionGetVerbose_ReturnsTransactionDetails() + { + // Arrange + await _client.ConnectAsync(); + const string txId = "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"; + + // Act - get verbose (decoded) + var verboseResult = await _client.SendRequestAsync( + "blockchain.transaction.get", + new object[] { txId, true }); + + // Assert + _output.WriteLine($"Transaction details for {txId}:"); + _output.WriteLine(verboseResult.ToString()); + + Assert.NotNull(verboseResult.ToString()); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task MultipleRequests_HandlesConcurrently() + { + // Arrange + await _client.ConnectAsync(); + + // Act - send multiple requests + var tasks = new List> + { + _client.SendRequestAsync("server.banner"), + _client.SendRequestAsync("blockchain.block.header", new object[] { 0 }), + _client.SendRequestAsync("blockchain.block.header", new object[] { 1 }), + _client.SendRequestAsync("blockchain.block.header", new object[] { 2 }) + }; + + var results = await Task.WhenAll(tasks); + + // Assert + _output.WriteLine($"Completed {results.Length} concurrent requests"); + for (int i = 0; i < results.Length; i++) + { + _output.WriteLine($" Result {i}: {results[i].Substring(0, Math.Min(50, results[i].Length))}..."); + } + + Assert.Equal(4, results.Length); + Assert.All(results, r => Assert.NotEmpty(r)); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task DisconnectAndReconnect_Works() + { + // Arrange + await _client.ConnectAsync(); + Assert.True(_client.IsConnected); + + // Act - disconnect + await _client.DisconnectAsync(); + Assert.False(_client.IsConnected); + + // Act - reconnect + await _client.ConnectAsync(); + + // Assert + Assert.True(_client.IsConnected); + _output.WriteLine("Successfully disconnected and reconnected"); + } +} diff --git a/src/Angor.Test/Services/Electrum/ElectrumIndexerServiceTests.cs b/src/Angor.Test/Services/Electrum/ElectrumIndexerServiceTests.cs new file mode 100644 index 000000000..74bc73330 --- /dev/null +++ b/src/Angor.Test/Services/Electrum/ElectrumIndexerServiceTests.cs @@ -0,0 +1,250 @@ +using Angor.Shared; +using Angor.Shared.Models; +using Angor.Shared.Services; +using Angor.Shared.Services.Indexer.Electrum; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using Xunit.Abstractions; + +namespace Angor.Test.Services.Electrum; + +public class ElectrumIndexerServiceTests : IAsyncLifetime +{ + private readonly ITestOutputHelper _output; + private ElectrumClientPool _clientPool = null!; + private ElectrumIndexerService _service = null!; + private readonly Mock _networkConfig; + + // Test configuration - change these to test different servers/networks + private const string TestServerHost = "electrum.blockstream.info"; + private const int TestServerPort = 50002; // SSL port + private const bool UseSsl = true; + + // Known testnet address for testing (Blockstream's faucet return address) + private const string TestAddress = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"; + + // Known mainnet transaction for testing + private const string TestTransactionId = "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"; + + public ElectrumIndexerServiceTests(ITestOutputHelper output) + { + _output = output; + _networkConfig = new Mock(); + + // Setup network configuration for testnet + var network = Angor.Shared.Networks.Networks.Bitcoin.Testnet(); + _networkConfig.Setup(x => x.GetNetwork()).Returns(network); + } + + public async Task InitializeAsync() + { + var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + var poolLogger = loggerFactory.CreateLogger(); + var serviceLogger = loggerFactory.CreateLogger(); + + var serverConfig = new ElectrumServerConfig + { + Host = TestServerHost, + Port = TestServerPort, + UseSsl = UseSsl, + Timeout = TimeSpan.FromSeconds(30) + }; + + _clientPool = new ElectrumClientPool(poolLogger, loggerFactory, new[] { serverConfig }); + _service = new ElectrumIndexerService(serviceLogger, _networkConfig.Object, _clientPool); + + _output.WriteLine($"Initialized with server: {TestServerHost}:{TestServerPort}"); + } + + public async Task DisposeAsync() + { + await _clientPool.DisconnectAllAsync(); + _clientPool.Dispose(); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task GetAddressBalances_WithValidAddress_ReturnsBalance() + { + // Arrange + var addresses = new List + { + new() { Address = TestAddress } + }; + + // Act + var result = await _service.GetAdressBalancesAsync(addresses); + + // Assert + _output.WriteLine($"Address: {TestAddress}"); + foreach (var balance in result) + { + _output.WriteLine($" Balance: {balance.balance} sats"); + _output.WriteLine($" Pending Received: {balance.pendingReceived} sats"); + _output.WriteLine($" Pending Sent: {balance.pendingSent} sats"); + } + + Assert.NotNull(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task FetchUtxo_WithValidAddress_ReturnsUtxos() + { + // Arrange & Act + var result = await _service.FetchUtxoAsync(TestAddress, 10, 0); + + // Assert + _output.WriteLine($"UTXOs for {TestAddress}:"); + if (result != null) + { + foreach (var utxo in result) + { + _output.WriteLine($" TxId: {utxo.outpoint.transactionId}"); + _output.WriteLine($" Index: {utxo.outpoint.outputIndex}"); + _output.WriteLine($" Value: {utxo.value} sats"); + _output.WriteLine($" Block: {utxo.blockIndex}"); + _output.WriteLine(" ---"); + } + } + + Assert.NotNull(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task FetchAddressHistory_WithValidAddress_ReturnsTransactions() + { + // Arrange & Act + var result = await _service.FetchAddressHistoryAsync(TestAddress); + + // Assert + _output.WriteLine($"Transaction history for {TestAddress}:"); + if (result != null) + { + _output.WriteLine($"Total transactions: {result.Count}"); + foreach (var tx in result.Take(5)) // Show first 5 + { + _output.WriteLine($" TxId: {tx.TransactionId}"); + _output.WriteLine($" Block: {tx.BlockIndex}"); + _output.WriteLine($" Timestamp: {tx.Timestamp}"); + _output.WriteLine(" ---"); + } + } + + Assert.NotNull(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task GetFeeEstimation_ReturnsValidFees() + { + // Arrange + var confirmations = new[] { 1, 3, 6 }; + + // Act + var result = await _service.GetFeeEstimationAsync(confirmations); + + // Assert + _output.WriteLine("Fee estimations:"); + if (result?.Fees != null) + { + foreach (var fee in result.Fees) + { + _output.WriteLine($" {fee.Confirmations} blocks: {fee.FeeRate} sat/kB"); + } + } + + Assert.NotNull(result); + Assert.NotEmpty(result.Fees); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task GetTransactionHexById_WithValidTxId_ReturnsHex() + { + // Arrange & Act + var result = await _service.GetTransactionHexByIdAsync(TestTransactionId); + + // Assert + _output.WriteLine($"Transaction hex for {TestTransactionId}:"); + _output.WriteLine($" Length: {result.Length} chars"); + _output.WriteLine($" Hex (first 100): {result.Substring(0, Math.Min(100, result.Length))}..."); + + Assert.NotNull(result); + Assert.NotEmpty(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task GetTransactionInfoById_WithValidTxId_ReturnsTransactionInfo() + { + // Arrange & Act + var result = await _service.GetTransactionInfoByIdAsync(TestTransactionId); + + // Assert + _output.WriteLine($"Transaction info for {TestTransactionId}:"); + if (result != null) + { + _output.WriteLine($" TxId: {result.TransactionId}"); + _output.WriteLine($" Version: {result.Version}"); + _output.WriteLine($" Size: {result.Size}"); + _output.WriteLine($" VSize: {result.VirtualSize}"); + _output.WriteLine($" Weight: {result.Weight}"); + _output.WriteLine($" Block Hash: {result.BlockHash}"); + _output.WriteLine($" Block Index: {result.BlockIndex}"); + _output.WriteLine($" Inputs: {result.Inputs?.Count() ?? 0}"); + _output.WriteLine($" Outputs: {result.Outputs?.Count() ?? 0}"); + } + + Assert.NotNull(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task GetIsSpentOutputsOnTransaction_WithValidTxId_ReturnsSpentStatus() + { + // Arrange & Act + var result = await _service.GetIsSpentOutputsOnTransactionAsync(TestTransactionId); + + // Assert + _output.WriteLine($"Spent outputs for {TestTransactionId}:"); + foreach (var item in result) + { + _output.WriteLine($" Output {item.index}: {(item.spent ? "SPENT" : "UNSPENT")}"); + } + + Assert.NotNull(result); + } + + [Fact(Skip = "Manual test - requires active Electrum server")] + public async Task CheckIndexerNetwork_WithValidServer_ReturnsOnlineStatus() + { + // Arrange + var serverUrl = $"ssl://{TestServerHost}:{TestServerPort}"; + + // Act + var result = await _service.CheckIndexerNetwork(serverUrl); + + // Assert + _output.WriteLine($"Server: {serverUrl}"); + _output.WriteLine($" Online: {result.IsOnline}"); + _output.WriteLine($" Genesis Hash: {result.GenesisHash}"); + + Assert.True(result.IsOnline); + Assert.NotNull(result.GenesisHash); + } + + [Fact(Skip = "Manual test - requires active Electrum server and funded wallet")] + public async Task PublishTransaction_WithValidTx_BroadcastsSuccessfully() + { + // This test requires a valid signed transaction hex + // It's included for completeness but should only be run with a real transaction + + // Arrange + var txHex = "YOUR_SIGNED_TRANSACTION_HEX_HERE"; + + // Act + var result = await _service.PublishTransactionAsync(txHex); + + // Assert + _output.WriteLine($"Publish result: {(string.IsNullOrEmpty(result) ? "SUCCESS" : result)}"); + + // Empty string means success + Assert.True(string.IsNullOrEmpty(result) || result.Contains("error")); + } +} diff --git a/src/Angor.Test/Services/IndexerComparisonTests.cs b/src/Angor.Test/Services/IndexerComparisonTests.cs index 72a3eebf8..1dfed102b 100644 --- a/src/Angor.Test/Services/IndexerComparisonTests.cs +++ b/src/Angor.Test/Services/IndexerComparisonTests.cs @@ -2,6 +2,7 @@ using Angor.Shared.Models; using Angor.Shared.Networks; using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; diff --git a/src/Angor.Test/WalletOperationsTest.cs b/src/Angor.Test/WalletOperationsTest.cs index 88f21a949..0d671053a 100644 --- a/src/Angor.Test/WalletOperationsTest.cs +++ b/src/Angor.Test/WalletOperationsTest.cs @@ -7,7 +7,6 @@ using Blockcore.NBitcoin.DataEncoders; using Microsoft.Extensions.Logging.Abstractions; using Moq; -using Angor.Shared.Services; using Angor.Test.Protocol; using Money = Blockcore.NBitcoin.Money; using uint256 = Blockcore.NBitcoin.uint256; @@ -15,6 +14,7 @@ using Blockcore.Networks; using Microsoft.Extensions.Logging; using Blockcore.NBitcoin.BIP32; +using Angor.Shared.Services.Indexer; namespace Angor.Test; diff --git a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/CreateInvestmentFromSpecificAddressIntegrationTests.cs b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/CreateInvestmentFromSpecificAddressIntegrationTests.cs index 7fdcbc2c9..8cd616823 100644 --- a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/CreateInvestmentFromSpecificAddressIntegrationTests.cs +++ b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/CreateInvestmentFromSpecificAddressIntegrationTests.cs @@ -10,7 +10,7 @@ using Angor.Shared.Protocol; using Angor.Shared.Protocol.Scripts; using Angor.Shared.Protocol.TransactionBuilders; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Blockcore.NBitcoin; using Blockcore.NBitcoin.DataEncoders; using Blockcore.Networks; diff --git a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/CreateInvestmentFromSpecificAddressTests.cs b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/CreateInvestmentFromSpecificAddressTests.cs index d41c0608a..cf6b64e53 100644 --- a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/CreateInvestmentFromSpecificAddressTests.cs +++ b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/CreateInvestmentFromSpecificAddressTests.cs @@ -9,7 +9,7 @@ using Angor.Shared.Protocol; using Angor.Shared.Protocol.Scripts; using Angor.Shared.Protocol.TransactionBuilders; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Blockcore.NBitcoin; using Blockcore.NBitcoin.BIP32; using Blockcore.NBitcoin.DataEncoders; diff --git a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/CreateInvestmentTests.cs b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/CreateInvestmentTests.cs index 7d8e4138c..7ded0f976 100644 --- a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/CreateInvestmentTests.cs +++ b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/CreateInvestmentTests.cs @@ -9,7 +9,7 @@ using Angor.Shared.Protocol; using Angor.Shared.Protocol.Scripts; using Angor.Shared.Protocol.TransactionBuilders; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Blockcore.NBitcoin; using Blockcore.NBitcoin.BIP32; using Blockcore.NBitcoin.DataEncoders; diff --git a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/MonitorAddressForFundsIntegrationTests.cs b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/MonitorAddressForFundsIntegrationTests.cs index b7f335fa0..0f0865af5 100644 --- a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/MonitorAddressForFundsIntegrationTests.cs +++ b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/MonitorAddressForFundsIntegrationTests.cs @@ -7,6 +7,7 @@ using Angor.Shared.Models; using Angor.Shared.Networks; using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Blockcore.Networks; using CSharpFunctionalExtensions; using Microsoft.Extensions.Logging.Abstractions; diff --git a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/MonitorAddressForFundsTests.cs b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/MonitorAddressForFundsTests.cs index 2375e39e5..32e4f1fc3 100644 --- a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/MonitorAddressForFundsTests.cs +++ b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/MonitorAddressForFundsTests.cs @@ -4,6 +4,7 @@ using Angor.Shared; using Angor.Shared.Models; using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Blockcore.NBitcoin; using Blockcore.NBitcoin.DataEncoders; using CSharpFunctionalExtensions; diff --git a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/TestDoubles/AngornetMinerFaucet.cs b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/TestDoubles/AngornetMinerFaucet.cs index 1f49894a1..26d3a1dbe 100644 --- a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/TestDoubles/AngornetMinerFaucet.cs +++ b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/TestDoubles/AngornetMinerFaucet.cs @@ -1,6 +1,6 @@ using Angor.Shared; using Angor.Shared.Models; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Blockcore.NBitcoin; using Blockcore.NBitcoin.BIP32; using Blockcore.Networks; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/CreateProjectInfo.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/CreateProjectInfo.cs index a2a272f10..b5ffd2cd7 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/CreateProjectInfo.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/CreateProjectInfo.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging; using Stage = Angor.Shared.Models.Stage; using Angor.Sdk.Funding.Projects.Dtos; +using Angor.Shared.Services.Indexer; namespace Angor.Sdk.Funding.Founder.Operations; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/CreateProjectKeys.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/CreateProjectKeys.cs index cd6dbff66..8b7d173e6 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/CreateProjectKeys.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/CreateProjectKeys.cs @@ -4,10 +4,10 @@ using Angor.Sdk.Funding.Founder.Dtos; using Angor.Data.Documents.Interfaces; using Angor.Shared.Models; -using Angor.Shared.Services; using CSharpFunctionalExtensions; using MediatR; using Microsoft.Extensions.Logging; +using Angor.Shared.Services.Indexer; namespace Angor.Sdk.Funding.Founder.Operations; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/CreateProjectProfile.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/CreateProjectProfile.cs index 43c78a293..97f335a30 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/CreateProjectProfile.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/CreateProjectProfile.cs @@ -14,6 +14,7 @@ using Nostr.Client.Messages.Metadata; using static Angor.Sdk.Funding.Founder.Operations.CreateProjectInfo; using Angor.Sdk.Funding.Projects.Dtos; +using Angor.Shared.Services.Indexer; namespace Angor.Sdk.Funding.Founder.Operations; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/GetProjectInvestments.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/GetProjectInvestments.cs index 1e6d9db8d..f3b68c892 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/GetProjectInvestments.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/GetProjectInvestments.cs @@ -4,7 +4,7 @@ using Angor.Sdk.Funding.Shared; using Angor.Shared; using Angor.Shared.Models; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Angor.Shared.Utilities; using Blockcore.Consensus.TransactionInfo; using CSharpFunctionalExtensions; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/PublishFounderTransaction.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/PublishFounderTransaction.cs index 33d947694..241737533 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/PublishFounderTransaction.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/PublishFounderTransaction.cs @@ -1,5 +1,5 @@ using Angor.Sdk.Funding.Shared; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using CSharpFunctionalExtensions; using MediatR; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/SpendStageFunds.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/SpendStageFunds.cs index e6b235a20..4198c25ee 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/SpendStageFunds.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Founder/Operations/SpendStageFunds.cs @@ -7,13 +7,13 @@ using Angor.Shared; using Angor.Shared.Models; using Angor.Shared.Protocol; -using Angor.Shared.Services; using Blockcore.NBitcoin; using Blockcore.NBitcoin.DataEncoders; using CSharpFunctionalExtensions; using MediatR; using Microsoft.Extensions.Logging; using Angor.Sdk.Funding.Projects; +using Angor.Shared.Services.Indexer; namespace Angor.Sdk.Funding.Founder.Operations; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/FundingContextServices.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/FundingContextServices.cs index f147c8d42..2118df25c 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/FundingContextServices.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/FundingContextServices.cs @@ -13,6 +13,7 @@ using Angor.Shared.Protocol.Scripts; using Angor.Shared.Protocol.TransactionBuilders; using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using MediatR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/GetInvestments.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/GetInvestments.cs index 3a4be9fcf..db8d34007 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/GetInvestments.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/GetInvestments.cs @@ -5,7 +5,7 @@ using Angor.Sdk.Funding.Services; using Angor.Sdk.Funding.Shared; using Angor.Shared; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using CSharpFunctionalExtensions; using MediatR; using Microsoft.Extensions.Logging; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/GetPenalties.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/GetPenalties.cs index 769946d21..53b6d1940 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/GetPenalties.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/GetPenalties.cs @@ -11,6 +11,7 @@ using Angor.Shared.Models; using Angor.Shared.Protocol; using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Blockcore.Consensus.ScriptInfo; using Blockcore.NBitcoin; using CSharpFunctionalExtensions; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/GetRecoveryStatus.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/GetRecoveryStatus.cs index 645323f55..b6524ea7a 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/GetRecoveryStatus.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/GetRecoveryStatus.cs @@ -7,12 +7,12 @@ using Angor.Shared; using Angor.Shared.Models; using Angor.Shared.Protocol; -using Angor.Shared.Services; using Blockcore.Consensus.TransactionInfo; using Blockcore.NBitcoin; using CSharpFunctionalExtensions; using MediatR; using Angor.Sdk.Funding.Projects; +using Angor.Shared.Services.Indexer; namespace Angor.Sdk.Funding.Investor.Operations; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/PublishAndStoreInvestorTransaction.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/PublishAndStoreInvestorTransaction.cs index 2f3e293b9..cf33cb73c 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/PublishAndStoreInvestorTransaction.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/PublishAndStoreInvestorTransaction.cs @@ -1,7 +1,7 @@ using Angor.Sdk.Funding.Investor.Domain; using Angor.Sdk.Funding.Shared; using Angor.Sdk.Funding.Shared.TransactionDrafts; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using CSharpFunctionalExtensions; using MediatR; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/RequestInvestmentSignatures.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/RequestInvestmentSignatures.cs index a006fd085..47cbcdf84 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/RequestInvestmentSignatures.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Investor/Operations/RequestInvestmentSignatures.cs @@ -15,6 +15,7 @@ using Blockcore.NBitcoin.DataEncoders; using CSharpFunctionalExtensions; using MediatR; +using Angor.Shared.Services.Indexer; namespace Angor.Sdk.Funding.Investor.Operations; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Projects/ProjectInvestmentsService.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Projects/ProjectInvestmentsService.cs index 8e4c44e0e..e749b4484 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Projects/ProjectInvestmentsService.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Projects/ProjectInvestmentsService.cs @@ -4,7 +4,7 @@ using Angor.Shared; using Angor.Shared.Models; using Angor.Shared.Protocol; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Angor.Shared.Utilities; using Blockcore.Consensus.TransactionInfo; using CSharpFunctionalExtensions; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Services/DocumentProjectService.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Services/DocumentProjectService.cs index 71e2a5e08..f6de6e7ba 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Services/DocumentProjectService.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Services/DocumentProjectService.cs @@ -7,6 +7,7 @@ using Angor.Shared.Services; using CSharpFunctionalExtensions; using Stage = Angor.Sdk.Funding.Projects.Domain.Stage; +using Angor.Shared.Services.Indexer; namespace Angor.Sdk.Funding.Services; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Services/ProjectService.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Services/ProjectService.cs index 99107b8a3..e73a44b08 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Services/ProjectService.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Services/ProjectService.cs @@ -5,6 +5,7 @@ using Angor.Sdk.Funding.Shared; using Angor.Shared.Models; using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using CSharpFunctionalExtensions; using Microsoft.Extensions.DependencyInjection; using Zafiro.CSharpFunctionalExtensions; diff --git a/src/Angor/Avalonia/Angor.Sdk/Funding/Services/TransactionService.cs b/src/Angor/Avalonia/Angor.Sdk/Funding/Services/TransactionService.cs index fe6fbb6fb..b436e0299 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Funding/Services/TransactionService.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Funding/Services/TransactionService.cs @@ -1,7 +1,7 @@ using Angor.Sdk.Funding.Projects.Domain; using Angor.Data.Documents.Interfaces; using Angor.Shared.Models; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; namespace Angor.Sdk.Funding.Services; diff --git a/src/Angor/Avalonia/Angor.Sdk/Wallet/Infrastructure/Impl/History/TransactionHistory.cs b/src/Angor/Avalonia/Angor.Sdk/Wallet/Infrastructure/Impl/History/TransactionHistory.cs index 5188f4d58..08d4f0ee2 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Wallet/Infrastructure/Impl/History/TransactionHistory.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Wallet/Infrastructure/Impl/History/TransactionHistory.cs @@ -4,7 +4,7 @@ using Angor.Sdk.Wallet.Infrastructure.History; using Angor.Shared; using Angor.Shared.Models; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using CSharpFunctionalExtensions; using Serilog; diff --git a/src/Angor/Avalonia/Angor.Sdk/Wallet/WalletContextServices.cs b/src/Angor/Avalonia/Angor.Sdk/Wallet/WalletContextServices.cs index 3c3154f69..ea3bfe6bd 100644 --- a/src/Angor/Avalonia/Angor.Sdk/Wallet/WalletContextServices.cs +++ b/src/Angor/Avalonia/Angor.Sdk/Wallet/WalletContextServices.cs @@ -9,6 +9,7 @@ using Angor.Shared; using Angor.Shared.Networks; using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; diff --git a/src/Angor/Shared/PsbtOperations.cs b/src/Angor/Shared/PsbtOperations.cs index 2d157b36b..7849d3089 100644 --- a/src/Angor/Shared/PsbtOperations.cs +++ b/src/Angor/Shared/PsbtOperations.cs @@ -1,5 +1,5 @@ using Angor.Shared.Models; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Blockcore.Consensus.ScriptInfo; using Blockcore.Consensus.TransactionInfo; using Blockcore.NBitcoin; diff --git a/src/Angor/Shared/Services/Indexer/Electrum/ElectrumAngorIndexerService.cs b/src/Angor/Shared/Services/Indexer/Electrum/ElectrumAngorIndexerService.cs new file mode 100644 index 000000000..25e37d2b6 --- /dev/null +++ b/src/Angor/Shared/Services/Indexer/Electrum/ElectrumAngorIndexerService.cs @@ -0,0 +1,464 @@ +using System.Text.Json; +using Angor.Shared.Models; +using Angor.Shared.Services.Indexer; +using Blockcore.Consensus.TransactionInfo; +using Microsoft.Extensions.Logging; + +namespace Angor.Shared.Services.Indexer.Electrum; + +/// +/// Electrum-based implementation of IAngorIndexerService. +/// Provides Angor-specific project and investment data by querying blockchain via Electrum protocol. +/// +public class ElectrumAngorIndexerService : IAngorIndexerService +{ + private readonly ILogger _logger; + private readonly INetworkConfiguration _networkConfiguration; + private readonly IDerivationOperations _derivationOperations; + private readonly ElectrumClientPool _clientPool; + private readonly MempoolIndexerMappers _mappers; + + public bool ReadFromAngorApi { get; set; } = false; + + public ElectrumAngorIndexerService( + ILogger logger, + INetworkConfiguration networkConfiguration, + IDerivationOperations derivationOperations, + ElectrumClientPool clientPool, + MempoolIndexerMappers mappers) + { + _logger = logger; + _networkConfiguration = networkConfiguration; + _derivationOperations = derivationOperations; + _clientPool = clientPool; + _mappers = mappers; + } + + public Task> GetProjectsAsync(int? offset, int limit) + { + // GetProjectsAsync is not supported via Electrum protocol + // This requires a centralized Angor indexer that maintains a list of all projects + _logger.LogWarning("GetProjectsAsync is not supported via Electrum protocol. Use Angor API indexer instead."); + return Task.FromResult(new List()); + } + + public async Task GetProjectByIdAsync(string projectId) + { + if (string.IsNullOrEmpty(projectId) || projectId.Length <= 1) + { + return null; + } + + try + { + // Convert project ID (Angor key) to Bitcoin address + var projectAddress = _derivationOperations.ConvertAngorKeyToBitcoinAddress(projectId); + + // Get all transactions for this address via Electrum + var transactions = await GetAddressTransactionsAsync(projectAddress); + + if (transactions == null || !transactions.Any()) + { + _logger.LogWarning("No transactions found for project {ProjectId}", projectId); + return null; + } + + // Convert to mempool format for compatibility with existing mapper + var mempoolTxs = transactions.Select(ConvertToMempoolTransaction).ToList(); + + // Use the existing mapper + return _mappers.ConvertTransactionsToProjectIndexerData(projectId, mempoolTxs); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching project by ID {ProjectId}", projectId); + return null; + } + } + + public async Task<(string projectId, ProjectStats? stats)> GetProjectStatsAsync(string projectId) + { + if (string.IsNullOrEmpty(projectId)) + { + return (projectId, null); + } + + try + { + // Convert project ID to Bitcoin address + var projectAddress = _derivationOperations.ConvertAngorKeyToBitcoinAddress(projectId); + + // Get all transactions for this address + var transactions = await GetAddressTransactionsAsync(projectAddress); + + if (transactions == null || !transactions.Any()) + { + _logger.LogWarning("No transactions found for project {ProjectId}", projectId); + return (projectId, null); + } + + // Convert to mempool format for compatibility with existing mapper + var mempoolTxs = transactions.Select(ConvertToMempoolTransaction).ToList(); + + // Use the existing mapper + var stats = _mappers.CalculateProjectStats(projectId, mempoolTxs); + + return (projectId, stats); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching project stats for {ProjectId}", projectId); + return (projectId, null); + } + } + + public async Task> GetInvestmentsAsync(string projectId) + { + if (string.IsNullOrEmpty(projectId)) + { + return new List(); + } + + try + { + // Convert project ID to Bitcoin address + var projectAddress = _derivationOperations.ConvertAngorKeyToBitcoinAddress(projectId); + + // Get all transactions for this address + var transactions = await GetAddressTransactionsAsync(projectAddress); + + if (transactions == null || !transactions.Any()) + { + _logger.LogWarning("No transactions found for project {ProjectId}", projectId); + return new List(); + } + + // Convert to mempool format for compatibility with existing mapper + var mempoolTxs = transactions.Select(ConvertToMempoolTransaction).ToList(); + + // Use the existing mapper + return _mappers.ConvertTransactionsToInvestments(projectId, mempoolTxs); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching investments for project {ProjectId}", projectId); + return new List(); + } + } + + public async Task GetInvestmentAsync(string projectId, string investorPubKey) + { + if (string.IsNullOrEmpty(projectId) || string.IsNullOrEmpty(investorPubKey)) + { + return null; + } + + try + { + var investments = await GetInvestmentsAsync(projectId); + + // Find the investment matching the investor public key + var investment = investments.FirstOrDefault(inv => + inv.InvestorPublicKey.Equals(investorPubKey, StringComparison.OrdinalIgnoreCase)); + + if (investment == null) + { + _logger.LogWarning("No investment found for project {ProjectId} and investor {InvestorPubKey}", + projectId, investorPubKey); + } + + return investment; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching investment for project {ProjectId} and investor {InvestorPubKey}", + projectId, investorPubKey); + return null; + } + } + + #region Private Helper Methods + + /// + /// Gets all transactions for an address via Electrum protocol. + /// + private async Task?> GetAddressTransactionsAsync(string address) + { + try + { + var network = _networkConfiguration.GetNetwork(); + var client = await _clientPool.GetClientAsync(); + var scriptHash = ElectrumScriptHashUtility.AddressToScriptHash(address, network); + + // Get transaction history for the address + var history = await client.SendRequestAsync>( + "blockchain.scripthash.get_history", + new object[] { scriptHash }); + + if (history == null || !history.Any()) + return new List(); + + // Fetch full transaction details for each transaction + var transactions = new List(); + foreach (var item in history) + { + try + { + var txJson = await client.SendRequestAsync( + "blockchain.transaction.get", + new object[] { item.TxHash, true }); + + var txInfo = ParseElectrumTransaction(txJson, item.Height); + if (txInfo != null) + { + transactions.Add(txInfo); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch transaction {TxHash}", item.TxHash); + } + } + + return transactions; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get transactions for address {Address}", address); + return null; + } + } + + /// + /// Parses Electrum JSON response to internal transaction info model. + /// + private AngorElectrumTransactionInfo? ParseElectrumTransaction(JsonElement txJson, int blockHeight) + { + try + { + var txInfo = new AngorElectrumTransactionInfo + { + Txid = txJson.GetProperty("txid").GetString() ?? string.Empty, + Version = (int)txJson.GetProperty("version").GetUInt32(), + Locktime = (int)txJson.GetProperty("locktime").GetInt64(), + Size = txJson.TryGetProperty("size", out var sizeEl) ? sizeEl.GetInt32() : 0, + Weight = txJson.TryGetProperty("weight", out var weightEl) ? weightEl.GetInt32() : 0, + Status = new AngorElectrumTxStatus + { + Confirmed = blockHeight > 0, + BlockHeight = blockHeight, + BlockHash = txJson.TryGetProperty("blockhash", out var bhEl) ? bhEl.GetString() : null, + BlockTime = txJson.TryGetProperty("blocktime", out var btEl) ? btEl.GetInt64() : 0 + } + }; + + // Parse inputs + if (txJson.TryGetProperty("vin", out var vinArray)) + { + foreach (var vin in vinArray.EnumerateArray()) + { + var input = new AngorElectrumVin + { + Txid = vin.TryGetProperty("txid", out var txidEl) ? txidEl.GetString() : null, + Vout = vin.TryGetProperty("vout", out var voutEl) ? voutEl.GetInt32() : 0, + Sequence = vin.TryGetProperty("sequence", out var seqEl) ? seqEl.GetInt64() : 0, + }; + + if (vin.TryGetProperty("scriptSig", out var scriptSigEl)) + { + input.Scriptsig = scriptSigEl.TryGetProperty("hex", out var hexEl) ? hexEl.GetString() : null; + input.Asm = scriptSigEl.TryGetProperty("asm", out var asmEl) ? asmEl.GetString() : null; + } + + if (vin.TryGetProperty("txinwitness", out var witnessEl)) + { + input.Witness = witnessEl.EnumerateArray() + .Select(w => w.GetString() ?? string.Empty) + .ToList(); + } + + // Try to get prevout info if available + if (vin.TryGetProperty("prevout", out var prevoutEl)) + { + input.Prevout = new AngorElectrumPrevOut + { + Value = (long)(prevoutEl.GetProperty("value").GetDouble() * 100_000_000), + Scriptpubkey = prevoutEl.TryGetProperty("scriptPubKey", out var spk) + ? spk.TryGetProperty("hex", out var spkHex) ? spkHex.GetString() : null + : null + }; + + if (prevoutEl.TryGetProperty("scriptPubKey", out var spkEl)) + { + if (spkEl.TryGetProperty("address", out var addrEl)) + { + input.Prevout.ScriptpubkeyAddress = addrEl.GetString(); + } + if (spkEl.TryGetProperty("type", out var typeEl)) + { + input.Prevout.ScriptpubkeyType = typeEl.GetString(); + } + } + } + + txInfo.Vin.Add(input); + } + } + + // Parse outputs + if (txJson.TryGetProperty("vout", out var voutArray)) + { + foreach (var vout in voutArray.EnumerateArray()) + { + var output = new AngorElectrumPrevOut + { + Value = (long)(vout.GetProperty("value").GetDouble() * 100_000_000) + }; + + if (vout.TryGetProperty("scriptPubKey", out var scriptPubKey)) + { + output.Scriptpubkey = scriptPubKey.TryGetProperty("hex", out var spkHex) ? spkHex.GetString() : null; + output.ScriptpubkeyAsm = scriptPubKey.TryGetProperty("asm", out var spkAsm) ? spkAsm.GetString() : null; + output.ScriptpubkeyType = scriptPubKey.TryGetProperty("type", out var typeEl) ? typeEl.GetString() : null; + + if (scriptPubKey.TryGetProperty("address", out var addrEl)) + { + output.ScriptpubkeyAddress = addrEl.GetString(); + } + else if (scriptPubKey.TryGetProperty("addresses", out var addrsEl) && addrsEl.GetArrayLength() > 0) + { + output.ScriptpubkeyAddress = addrsEl[0].GetString(); + } + } + + txInfo.Vout.Add(output); + } + } + + return txInfo; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse Electrum transaction"); + return null; + } + } + + /// + /// Converts internal transaction info to MempoolSpaceIndexerApi.MempoolTransaction for mapper compatibility. + /// + private MempoolSpaceIndexerApi.MempoolTransaction ConvertToMempoolTransaction(AngorElectrumTransactionInfo tx) + { + return new MempoolSpaceIndexerApi.MempoolTransaction + { + Txid = tx.Txid, + Version = tx.Version, + Locktime = tx.Locktime, + Size = tx.Size, + Weight = tx.Weight, + Fee = tx.Fee, + Status = new MempoolSpaceIndexerApi.UtxoStatus + { + Confirmed = tx.Status.Confirmed, + BlockHeight = tx.Status.BlockHeight, + BlockHash = tx.Status.BlockHash ?? string.Empty, + BlockTime = tx.Status.BlockTime + }, + Vin = tx.Vin.Select(vin => new MempoolSpaceIndexerApi.Vin + { + Txid = vin.Txid ?? string.Empty, + Vout = vin.Vout, + Scriptsig = vin.Scriptsig ?? string.Empty, + Asm = vin.Asm ?? string.Empty, + Sequence = vin.Sequence, + Witness = vin.Witness, + Prevout = vin.Prevout != null ? new MempoolSpaceIndexerApi.PrevOut + { + Value = vin.Prevout.Value, + Scriptpubkey = vin.Prevout.Scriptpubkey ?? string.Empty, + ScriptpubkeyAddress = vin.Prevout.ScriptpubkeyAddress ?? string.Empty, + ScriptpubkeyAsm = vin.Prevout.ScriptpubkeyAsm ?? string.Empty, + ScriptpubkeyType = vin.Prevout.ScriptpubkeyType ?? string.Empty + } : new MempoolSpaceIndexerApi.PrevOut() + }).ToList(), + Vout = tx.Vout.Select(vout => new MempoolSpaceIndexerApi.PrevOut + { + Value = vout.Value, + Scriptpubkey = vout.Scriptpubkey ?? string.Empty, + ScriptpubkeyAddress = vout.ScriptpubkeyAddress ?? string.Empty, + ScriptpubkeyAsm = vout.ScriptpubkeyAsm ?? string.Empty, + ScriptpubkeyType = vout.ScriptpubkeyType ?? string.Empty + }).ToList() + }; + } + + #endregion +} + +#region Internal Models for Angor Electrum Service + +/// +/// Internal model for Electrum transaction history item. +/// +internal class AngorElectrumHistoryItem +{ + public int Height { get; set; } + public string TxHash { get; set; } = string.Empty; + public long Fee { get; set; } +} + +/// +/// Internal model for parsed Electrum transaction. +/// +internal class AngorElectrumTransactionInfo +{ + public string Txid { get; set; } = string.Empty; + public int Version { get; set; } + public int Locktime { get; set; } + public int Size { get; set; } + public int Weight { get; set; } + public int Fee { get; set; } + public AngorElectrumTxStatus Status { get; set; } = new(); + public List Vin { get; set; } = new(); + public List Vout { get; set; } = new(); +} + +/// +/// Transaction status information. +/// +internal class AngorElectrumTxStatus +{ + public bool Confirmed { get; set; } + public int BlockHeight { get; set; } + public string? BlockHash { get; set; } + public long BlockTime { get; set; } +} + +/// +/// Transaction input. +/// +internal class AngorElectrumVin +{ + public bool IsCoinbase { get; set; } + public AngorElectrumPrevOut? Prevout { get; set; } + public string? Scriptsig { get; set; } + public string? Asm { get; set; } + public long Sequence { get; set; } + public string? Txid { get; set; } + public int Vout { get; set; } + public List Witness { get; set; } = new(); +} + +/// +/// Previous output / current output. +/// +internal class AngorElectrumPrevOut +{ + public long Value { get; set; } + public string? Scriptpubkey { get; set; } + public string? ScriptpubkeyAddress { get; set; } + public string? ScriptpubkeyAsm { get; set; } + public string? ScriptpubkeyType { get; set; } +} + +#endregion diff --git a/src/Angor/Shared/Services/Indexer/Electrum/ElectrumClient.cs b/src/Angor/Shared/Services/Indexer/Electrum/ElectrumClient.cs new file mode 100644 index 000000000..41efd8d11 --- /dev/null +++ b/src/Angor/Shared/Services/Indexer/Electrum/ElectrumClient.cs @@ -0,0 +1,385 @@ +using System.Collections.Concurrent; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Angor.Shared.Services.Indexer.Electrum; + +/// +/// Electrum JSON-RPC client over SSL/TCP. +/// Implements persistent connections with request/response correlation. +/// +public class ElectrumClient : IDisposable +{ + private readonly ILogger _logger; + private readonly ElectrumServerConfig _config; + private readonly SemaphoreSlim _connectionLock = new(1, 1); + private readonly ConcurrentDictionary> _pendingRequests = new(); + private readonly CancellationTokenSource _readLoopCts = new(); + + private TcpClient? _tcpClient; + private SslStream? _sslStream; + private StreamReader? _reader; + private StreamWriter? _writer; + private Task? _readLoopTask; + private int _requestId; + private bool _disposed; + private bool _isConnected; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public bool IsConnected => _isConnected && _tcpClient?.Connected == true; + public string ServerUrl => $"{_config.Host}:{_config.Port}"; + + public ElectrumClient(ILogger logger, ElectrumServerConfig config) + { + _logger = logger; + _config = config; + } + + public async Task ConnectAsync(CancellationToken cancellationToken = default) + { + await _connectionLock.WaitAsync(cancellationToken); + try + { + if (IsConnected) + return; + + _logger.LogInformation("Connecting to Electrum server {Host}:{Port}", _config.Host, _config.Port); + + _tcpClient = new TcpClient + { + ReceiveTimeout = (int)_config.Timeout.TotalMilliseconds, + SendTimeout = (int)_config.Timeout.TotalMilliseconds + }; + + await _tcpClient.ConnectAsync(_config.Host, _config.Port, cancellationToken); + + if (_config.UseSsl) + { + _sslStream = new SslStream( + _tcpClient.GetStream(), + false, + ValidateServerCertificate, + null); + + await _sslStream.AuthenticateAsClientAsync( + new SslClientAuthenticationOptions + { + TargetHost = _config.Host, + EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 + }, + cancellationToken); + + // Use UTF-8 without BOM - Electrum servers reject JSON with BOM prefix + var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + _reader = new StreamReader(_sslStream, utf8NoBom); + _writer = new StreamWriter(_sslStream, utf8NoBom) { AutoFlush = true }; + } + else + { + var stream = _tcpClient.GetStream(); + // Use UTF-8 without BOM - Electrum servers reject JSON with BOM prefix + var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + _reader = new StreamReader(stream, utf8NoBom); + _writer = new StreamWriter(stream, utf8NoBom) { AutoFlush = true }; + } + + _isConnected = true; + + // Start background read loop + _readLoopTask = Task.Run(() => ReadLoopAsync(_readLoopCts.Token), _readLoopCts.Token); + + // Negotiate protocol version + await NegotiateProtocolVersionAsync(cancellationToken); + + _logger.LogInformation("Connected to Electrum server {Host}:{Port}", _config.Host, _config.Port); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect to Electrum server {Host}:{Port}", _config.Host, _config.Port); + await DisconnectAsync(); + throw; + } + finally + { + _connectionLock.Release(); + } + } + + private bool ValidateServerCertificate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) + { + if (_config.AllowSelfSignedCertificates) + return true; + + if (sslPolicyErrors == SslPolicyErrors.None) + return true; + + _logger.LogWarning("SSL certificate validation error: {Errors}", sslPolicyErrors); + return false; + } + + private async Task NegotiateProtocolVersionAsync(CancellationToken cancellationToken) + { + var result = await SendRequestAsync( + "server.version", + new object[] { "Angor/1.0", "1.4" }, + cancellationToken); + + if (result is { Length: >= 2 }) + { + _logger.LogDebug("Electrum protocol version: {Version}, Server: {Server}", + result[1].GetString(), result[0].GetString()); + } + } + + public async Task DisconnectAsync() + { + await _connectionLock.WaitAsync(); + try + { + _isConnected = false; + + _readLoopCts.Cancel(); + + if (_readLoopTask != null) + { + try + { + await _readLoopTask.WaitAsync(TimeSpan.FromSeconds(2)); + } + catch (TimeoutException) { } + catch (OperationCanceledException) { } + } + + _writer?.Dispose(); + _reader?.Dispose(); + _sslStream?.Dispose(); + _tcpClient?.Dispose(); + + _writer = null; + _reader = null; + _sslStream = null; + _tcpClient = null; + + // Complete any pending requests with cancellation + foreach (var pending in _pendingRequests) + { + pending.Value.TrySetCanceled(); + } + _pendingRequests.Clear(); + + _logger.LogInformation("Disconnected from Electrum server"); + } + finally + { + _connectionLock.Release(); + } + } + + private async Task ReadLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested && _reader != null) + { + var line = await _reader.ReadLineAsync(cancellationToken); + if (line == null) + { + _logger.LogWarning("Electrum server closed connection"); + _isConnected = false; + break; + } + + try + { + ProcessResponse(line); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing Electrum response: {Line}", line); + } + } + } + catch (OperationCanceledException) + { + // Expected during shutdown + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in Electrum read loop"); + _isConnected = false; + } + } + + private void ProcessResponse(string line) + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + if (root.TryGetProperty("id", out var idElement)) + { + var id = idElement.GetInt32(); + if (_pendingRequests.TryRemove(id, out var tcs)) + { + if (root.TryGetProperty("error", out var error) && error.ValueKind != JsonValueKind.Null) + { + // Handle both string errors and object errors with a message property + string? errorMessage; + if (error.ValueKind == JsonValueKind.String) + { + errorMessage = error.GetString(); + } + else if (error.ValueKind == JsonValueKind.Object && error.TryGetProperty("message", out var msg)) + { + errorMessage = msg.GetString(); + } + else + { + errorMessage = error.ToString(); + } + tcs.SetException(new ElectrumException(errorMessage ?? "Unknown error")); + } + else if (root.TryGetProperty("result", out var result)) + { + tcs.SetResult(result.Clone()); + } + else + { + tcs.SetException(new ElectrumException("Invalid response format")); + } + } + } + else + { + // Server notification (no id) + if (root.TryGetProperty("method", out var method)) + { + _logger.LogDebug("Received notification: {Method}", method.GetString()); + } + } + } + + public async Task SendRequestAsync(string method, object[]? parameters = null, CancellationToken cancellationToken = default) + { + var result = await SendRequestInternalAsync(method, parameters, cancellationToken); + return JsonSerializer.Deserialize(result.GetRawText(), JsonOptions)!; + } + + public async Task SendRequestAsync(string method, object[]? parameters = null, CancellationToken cancellationToken = default) + { + return await SendRequestInternalAsync(method, parameters, cancellationToken); + } + + private async Task SendRequestInternalAsync(string method, object[]? parameters, CancellationToken cancellationToken) + { + if (!IsConnected) + { + await ConnectAsync(cancellationToken); + } + + var id = Interlocked.Increment(ref _requestId); + var request = new ElectrumRequest + { + Id = id, + Method = method, + Params = parameters ?? Array.Empty() + }; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingRequests[id] = tcs; + + try + { + var json = JsonSerializer.Serialize(request, JsonOptions); + _logger.LogTrace("Sending Electrum request: {Request}", json); + + if (_writer == null) + throw new ElectrumException("Not connected to server"); + + await _writer.WriteLineAsync(json); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_config.Timeout); + + try + { + return await tcs.Task.WaitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"Electrum request timed out: {method}"); + } + } + catch + { + _pendingRequests.TryRemove(id, out _); + throw; + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _readLoopCts.Cancel(); + _readLoopCts.Dispose(); + _connectionLock.Dispose(); + _writer?.Dispose(); + _reader?.Dispose(); + _sslStream?.Dispose(); + _tcpClient?.Dispose(); + + GC.SuppressFinalize(this); + } +} + +/// +/// Electrum JSON-RPC request format. +/// +internal class ElectrumRequest +{ + [JsonPropertyName("jsonrpc")] + public string JsonRpc { get; } = "2.0"; + + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("method")] + public string Method { get; set; } = string.Empty; + + [JsonPropertyName("params")] + public object[] Params { get; set; } = Array.Empty(); +} + +/// +/// Exception thrown for Electrum protocol errors. +/// +public class ElectrumException : Exception +{ + public ElectrumException(string message) : base(message) { } + public ElectrumException(string message, Exception innerException) : base(message, innerException) { } +} + +/// +/// Configuration for an Electrum server connection. +/// +public class ElectrumServerConfig +{ + public string Host { get; set; } = string.Empty; + public int Port { get; set; } = 50002; + public bool UseSsl { get; set; } = true; + public bool AllowSelfSignedCertificates { get; set; } = true; + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + public string Network { get; set; } = "mainnet"; +} diff --git a/src/Angor/Shared/Services/Indexer/Electrum/ElectrumClientPool.cs b/src/Angor/Shared/Services/Indexer/Electrum/ElectrumClientPool.cs new file mode 100644 index 000000000..adb1632d4 --- /dev/null +++ b/src/Angor/Shared/Services/Indexer/Electrum/ElectrumClientPool.cs @@ -0,0 +1,222 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace Angor.Shared.Services.Indexer.Electrum; + +/// +/// Pool for managing multiple ElectrumClient connections. +/// Handles connection lifecycle, failover, and server selection. +/// +public class ElectrumClientPool : IDisposable +{ + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + private readonly ConcurrentDictionary _clients = new(); + private readonly SemaphoreSlim _connectionLock = new(1, 1); + private readonly List _serverConfigs; + private string? _primaryServerKey; + private bool _disposed; + + public ElectrumClientPool( + ILogger logger, + ILoggerFactory loggerFactory, + IEnumerable serverConfigs) + { + _logger = logger; + _loggerFactory = loggerFactory; + _serverConfigs = serverConfigs.ToList(); + } + + /// + /// Gets a connected ElectrumClient, preferring the primary server. + /// Falls back to other servers if primary is unavailable. + /// + public async Task GetClientAsync(CancellationToken cancellationToken = default) + { + await _connectionLock.WaitAsync(cancellationToken); + try + { + // Try primary server first + if (!string.IsNullOrEmpty(_primaryServerKey) && _clients.TryGetValue(_primaryServerKey, out var primaryClient)) + { + if (primaryClient.IsConnected) + return primaryClient; + } + + // Try to connect to any server + foreach (var config in _serverConfigs) + { + var key = GetServerKey(config); + + if (_clients.TryGetValue(key, out var existingClient)) + { + if (existingClient.IsConnected) + { + _primaryServerKey = key; + return existingClient; + } + + // Clean up disconnected client + _clients.TryRemove(key, out _); + existingClient.Dispose(); + } + + try + { + var client = await CreateAndConnectClientAsync(config, cancellationToken); + _clients[key] = client; + _primaryServerKey = key; + _logger.LogInformation("Connected to Electrum server {Server}", key); + return client; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to connect to Electrum server {Host}:{Port}", config.Host, config.Port); + } + } + + throw new ElectrumException("Unable to connect to any Electrum server"); + } + finally + { + _connectionLock.Release(); + } + } + + /// + /// Gets a client for a specific server configuration. + /// + public async Task GetClientForServerAsync(ElectrumServerConfig config, CancellationToken cancellationToken = default) + { + var key = GetServerKey(config); + + await _connectionLock.WaitAsync(cancellationToken); + try + { + if (_clients.TryGetValue(key, out var existingClient) && existingClient.IsConnected) + { + return existingClient; + } + + var client = await CreateAndConnectClientAsync(config, cancellationToken); + _clients[key] = client; + return client; + } + finally + { + _connectionLock.Release(); + } + } + + /// + /// Adds a new server configuration to the pool. + /// + public void AddServer(ElectrumServerConfig config) + { + if (!_serverConfigs.Any(c => GetServerKey(c) == GetServerKey(config))) + { + _serverConfigs.Add(config); + } + } + + /// + /// Removes a server from the pool and disconnects any existing connection. + /// + public async Task RemoveServerAsync(string host, int port) + { + var key = $"{host}:{port}"; + + var config = _serverConfigs.FirstOrDefault(c => GetServerKey(c) == key); + if (config != null) + { + _serverConfigs.Remove(config); + } + + if (_clients.TryRemove(key, out var client)) + { + await client.DisconnectAsync(); + client.Dispose(); + } + + if (_primaryServerKey == key) + { + _primaryServerKey = null; + } + } + + /// + /// Sets the primary server. The pool will prefer this server for requests. + /// + public void SetPrimaryServer(string host, int port) + { + _primaryServerKey = $"{host}:{port}"; + } + + /// + /// Disconnects all clients. + /// + public async Task DisconnectAllAsync() + { + foreach (var client in _clients.Values) + { + try + { + await client.DisconnectAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error disconnecting from Electrum server"); + } + } + + foreach (var client in _clients.Values) + { + client.Dispose(); + } + + _clients.Clear(); + _primaryServerKey = null; + } + + /// + /// Gets the list of configured servers. + /// + public IReadOnlyList GetServers() => _serverConfigs.AsReadOnly(); + + /// + /// Checks if a specific server is connected. + /// + public bool IsServerConnected(string host, int port) + { + var key = $"{host}:{port}"; + return _clients.TryGetValue(key, out var client) && client.IsConnected; + } + + private async Task CreateAndConnectClientAsync(ElectrumServerConfig config, CancellationToken cancellationToken) + { + var clientLogger = _loggerFactory.CreateLogger(); + var client = new ElectrumClient(clientLogger, config); + await client.ConnectAsync(cancellationToken); + return client; + } + + private static string GetServerKey(ElectrumServerConfig config) => $"{config.Host}:{config.Port}"; + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + foreach (var client in _clients.Values) + { + client.Dispose(); + } + + _clients.Clear(); + _connectionLock.Dispose(); + + GC.SuppressFinalize(this); + } +} diff --git a/src/Angor/Shared/Services/Indexer/Electrum/ElectrumIndexerService.cs b/src/Angor/Shared/Services/Indexer/Electrum/ElectrumIndexerService.cs new file mode 100644 index 000000000..272ccddd8 --- /dev/null +++ b/src/Angor/Shared/Services/Indexer/Electrum/ElectrumIndexerService.cs @@ -0,0 +1,559 @@ +using System.Text.Json; +using Angor.Shared.Models; +using Angor.Shared.Services.Indexer; +using Blockcore.Consensus.TransactionInfo; +using Blockcore.NBitcoin.DataEncoders; +using Microsoft.Extensions.Logging; + +namespace Angor.Shared.Services.Indexer.Electrum; + +/// +/// Electrum-based implementation of IIndexerService. +/// Uses Electrum protocol over SSL/TCP to query blockchain data. +/// +public class ElectrumIndexerService : IIndexerService +{ + private readonly ILogger _logger; + private readonly INetworkConfiguration _networkConfiguration; + private readonly ElectrumClientPool _clientPool; + + public ElectrumIndexerService( + ILogger logger, + INetworkConfiguration networkConfiguration, + ElectrumClientPool clientPool) + { + _logger = logger; + _networkConfiguration = networkConfiguration; + _clientPool = clientPool; + } + + public async Task PublishTransactionAsync(string trxHex) + { + try + { + var client = await _clientPool.GetClientAsync(); + var result = await client.SendRequestAsync( + "blockchain.transaction.broadcast", + new object[] { trxHex }); + + _logger.LogInformation("Transaction broadcast successful: {TxId}", result); + return string.Empty; // Success + } + catch (ElectrumException ex) + { + _logger.LogError(ex, "Failed to broadcast transaction"); + return ex.Message; + } + } + + public async Task GetAdressBalancesAsync(List data, bool includeUnconfirmed = false) + { + var network = _networkConfiguration.GetNetwork(); + var client = await _clientPool.GetClientAsync(); + var results = new List(); + + // Process addresses in parallel with batching + var tasks = data.Select(async addressInfo => + { + try + { + var scriptHash = ElectrumScriptHashUtility.AddressToScriptHash(addressInfo.Address, network); + var balance = await client.SendRequestAsync( + "blockchain.scripthash.get_balance", + new object[] { scriptHash }); + + if (balance.Confirmed > 0 || balance.Unconfirmed != 0) + { + return new AddressBalance + { + address = addressInfo.Address, + balance = balance.Confirmed, + pendingReceived = balance.Unconfirmed > 0 ? balance.Unconfirmed : 0, + pendingSent = balance.Unconfirmed < 0 ? Math.Abs(balance.Unconfirmed) : 0 + }; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get balance for address {Address}", addressInfo.Address); + } + return null; + }); + + var balances = await Task.WhenAll(tasks); + return balances.Where(b => b != null).Cast().ToArray(); + } + + public async Task?> FetchUtxoAsync(string address, int limit, int offset) + { + try + { + var network = _networkConfiguration.GetNetwork(); + var client = await _clientPool.GetClientAsync(); + var scriptHash = ElectrumScriptHashUtility.AddressToScriptHash(address, network); + + var utxos = await client.SendRequestAsync>( + "blockchain.scripthash.listunspent", + new object[] { scriptHash }); + + if (utxos == null) + return new List(); + + // Apply offset and limit + var pagedUtxos = utxos.Skip(offset).Take(limit); + + var utxoDataList = new List(); + foreach (var utxo in pagedUtxos) + { + // Get the transaction to get the scriptPubKey + var txHex = await GetTransactionHexByIdAsync(utxo.TxHash); + var tx = network.CreateTransaction(txHex); + var output = tx.Outputs[utxo.TxPos]; + + var data = new UtxoData + { + address = address, + scriptHex = output.ScriptPubKey.ToHex(), + outpoint = new Outpoint(utxo.TxHash, utxo.TxPos), + value = utxo.Value, + }; + + if (utxo.Height > 0) + { + data.blockIndex = utxo.Height; + } + + utxoDataList.Add(data); + } + + return utxoDataList; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch UTXOs for address {Address}", address); + throw; + } + } + + public async Task?> FetchAddressHistoryAsync(string address, string? afterTrxId = null) + { + try + { + var network = _networkConfiguration.GetNetwork(); + var client = await _clientPool.GetClientAsync(); + var scriptHash = ElectrumScriptHashUtility.AddressToScriptHash(address, network); + + var history = await client.SendRequestAsync>( + "blockchain.scripthash.get_history", + new object[] { scriptHash }); + + if (history == null || !history.Any()) + return new List(); + + // Filter transactions after the specified transaction ID + if (!string.IsNullOrEmpty(afterTrxId)) + { + var afterIndex = history.FindIndex(h => h.TxHash == afterTrxId); + if (afterIndex >= 0) + { + history = history.Skip(afterIndex + 1).ToList(); + } + } + + var transactions = new List(); + foreach (var item in history) + { + var txInfo = await GetTransactionInfoByIdAsync(item.TxHash); + if (txInfo != null) + { + transactions.Add(txInfo); + } + } + + return transactions; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch address history for {Address}", address); + throw; + } + } + + public async Task GetFeeEstimationAsync(int[] confirmations) + { + try + { + var client = await _clientPool.GetClientAsync(); + var fees = new List(); + + foreach (var blocks in confirmations) + { + try + { + // Electrum returns fee in BTC/kB, we need satoshis/kB + var feeRate = await client.SendRequestAsync( + "blockchain.estimatefee", + new object[] { blocks }); + + if (feeRate > 0) + { + // Convert BTC/kB to satoshis/kB (sat/vB * 1000) + var satoshisPerKb = (long)(feeRate * 100_000_000); + fees.Add(new FeeEstimation + { + Confirmations = blocks, + FeeRate = satoshisPerKb + }); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to estimate fee for {Blocks} blocks", blocks); + } + } + + return fees.Any() ? new FeeEstimations { Fees = fees } : null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get fee estimations"); + return null; + } + } + + public async Task GetTransactionHexByIdAsync(string transactionId) + { + try + { + var client = await _clientPool.GetClientAsync(); + var txHex = await client.SendRequestAsync( + "blockchain.transaction.get", + new object[] { transactionId, false }); + + return txHex; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get transaction hex for {TxId}", transactionId); + throw; + } + } + + public async Task GetTransactionInfoByIdAsync(string transactionId) + { + try + { + var client = await _clientPool.GetClientAsync(); + var txJson = await client.SendRequestAsync( + "blockchain.transaction.get", +new object[] { transactionId, true }); + + return MapElectrumTransactionToQueryTransaction(txJson); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get transaction info for {TxId}", transactionId); + throw; + } + } + + public async Task> GetIsSpentOutputsOnTransactionAsync(string transactionId) + { + try + { + var network = _networkConfiguration.GetNetwork(); + var client = await _clientPool.GetClientAsync(); + + // Get the transaction first to know how many outputs it has + var txHex = await GetTransactionHexByIdAsync(transactionId); + var tx = network.CreateTransaction(txHex); + + var results = new List<(int index, bool spent)>(); + + for (int i = 0; i < tx.Outputs.Count; i++) + { + var output = tx.Outputs[i]; + var scriptHash = ElectrumScriptHashUtility.ScriptToScriptHash(output.ScriptPubKey); + + // Get unspent outputs for this script + var utxos = await client.SendRequestAsync>( + "blockchain.scripthash.listunspent", + new object[] { scriptHash }); + + // Check if this specific output is in the unspent list + var isUnspent = utxos?.Any(u => u.TxHash == transactionId && u.TxPos == i) ?? false; + results.Add((i, !isUnspent)); + } + + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to check spent outputs for {TxId}", transactionId); + throw; + } + } + + public async Task<(bool IsOnline, string? GenesisHash)> CheckIndexerNetwork(string indexerUrl) + { + try + { + // Parse the URL to extract host and port + if (!TryParseElectrumUrl(indexerUrl, out var host, out var port, out var useSsl)) + { + _logger.LogWarning("Invalid Electrum URL format: {Url}", indexerUrl); + return (false, null); + } + + var config = new ElectrumServerConfig + { + Host = host, + Port = port, + UseSsl = useSsl, + Timeout = TimeSpan.FromSeconds(10) + }; + + var client = await _clientPool.GetClientForServerAsync(config); + + // Get the block header at height 0 (genesis block) + var genesisHeader = await client.SendRequestAsync( + "blockchain.block.header", + new object[] { 0 }); + + // The block hash is the double SHA256 of the header, but Electrum returns it directly + // We need to get the block hash separately + var blockHash = await GetBlockHashAtHeight(client, 0); + + return (true, blockHash); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking Electrum server network: {Url}", indexerUrl); + return (false, null); + } + } + + public bool ValidateGenesisBlockHash(string fetchedHash, string expectedHash) + { + return fetchedHash.StartsWith(expectedHash, StringComparison.OrdinalIgnoreCase) || string.IsNullOrEmpty(fetchedHash); + } + + #region Private Helper Methods + + private async Task GetBlockHashAtHeight(ElectrumClient client, int height) + { + try + { + // Get the block header at the specified height + var header = await client.SendRequestAsync( + "blockchain.block.header", + new object[] { height }); + + if (string.IsNullOrEmpty(header)) + return null; + + // The block hash is calculated by double SHA256 of the header + var headerBytes = Encoders.Hex.DecodeData(header); + var hash1 = System.Security.Cryptography.SHA256.HashData(headerBytes); + var hash2 = System.Security.Cryptography.SHA256.HashData(hash1); + Array.Reverse(hash2); // Bitcoin uses little-endian + return Encoders.Hex.EncodeData(hash2); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get block hash at height {Height}", height); + return null; + } + } + + private static bool TryParseElectrumUrl(string url, out string host, out int port, out bool useSsl) + { + host = string.Empty; + port = 50002; + useSsl = true; + + try + { + // Handle formats like: + // - ssl://host:port + // - tcp://host:port + // - host:port (defaults to SSL) + // - electrum://host:port + + if (url.StartsWith("tcp://", StringComparison.OrdinalIgnoreCase)) + { + useSsl = false; + url = url[6..]; + } + else if (url.StartsWith("ssl://", StringComparison.OrdinalIgnoreCase)) + { + useSsl = true; + url = url[6..]; + } + else if (url.StartsWith("electrum://", StringComparison.OrdinalIgnoreCase)) + { + url = url[11..]; + } + + var parts = url.Split(':'); + if (parts.Length >= 1) + { + host = parts[0]; + if (parts.Length >= 2 && int.TryParse(parts[1], out var parsedPort)) + { + port = parsedPort; + } + return !string.IsNullOrEmpty(host); + } + + return false; + } + catch + { + return false; + } + } + + private QueryTransaction? MapElectrumTransactionToQueryTransaction(JsonElement txJson) + { + try + { + var txId = txJson.GetProperty("txid").GetString(); + var version = txJson.GetProperty("version").GetUInt32(); + var locktime = txJson.GetProperty("locktime").GetInt64(); + var size = txJson.TryGetProperty("size", out var sizeEl) ? sizeEl.GetInt32() : 0; + var vsize = txJson.TryGetProperty("vsize", out var vsizeEl) ? vsizeEl.GetInt32() : size; + var weight = txJson.TryGetProperty("weight", out var weightEl) ? weightEl.GetInt32() : vsize * 4; + + string? blockHash = null; + long? blockHeight = null; + long timestamp = 0; + + if (txJson.TryGetProperty("blockhash", out var blockHashEl)) + { + blockHash = blockHashEl.GetString(); + } + + if (txJson.TryGetProperty("blocktime", out var blockTimeEl)) + { + timestamp = blockTimeEl.GetInt64(); + } + + // Parse inputs + var inputs = new List(); + if (txJson.TryGetProperty("vin", out var vinArray)) + { + foreach (var vin in vinArray.EnumerateArray()) + { + var input = new QueryTransactionInput + { + InputTransactionId = vin.TryGetProperty("txid", out var txidEl) ? txidEl.GetString() : null, + InputIndex = vin.TryGetProperty("vout", out var voutEl) ? voutEl.GetInt32() : 0, + SequenceLock = vin.TryGetProperty("sequence", out var seqEl) ? seqEl.GetInt64().ToString() : null, + ScriptSig = vin.TryGetProperty("scriptSig", out var scriptSigEl) && scriptSigEl.TryGetProperty("hex", out var hexEl) + ? hexEl.GetString() : null, + }; + + if (vin.TryGetProperty("txinwitness", out var witnessEl)) + { + var witnessData = witnessEl.EnumerateArray() + .Select(w => Encoders.Hex.DecodeData(w.GetString() ?? "")) + .ToArray(); + input.WitScript = new WitScript(witnessData).ToScript().ToHex(); + } + + inputs.Add(input); + } + } + + // Parse outputs + var outputs = new List(); + if (txJson.TryGetProperty("vout", out var voutArray)) + { + var index = 0; + foreach (var vout in voutArray.EnumerateArray()) + { + var output = new QueryTransactionOutput + { + Index = index++, + Balance = (long)(vout.GetProperty("value").GetDouble() * 100_000_000), + }; + + if (vout.TryGetProperty("scriptPubKey", out var scriptPubKey)) + { + output.ScriptPubKey = scriptPubKey.TryGetProperty("hex", out var spkHex) ? spkHex.GetString() : null; + output.ScriptPubKeyAsm = scriptPubKey.TryGetProperty("asm", out var spkAsm) ? spkAsm.GetString() : null; + output.OutputType = scriptPubKey.TryGetProperty("type", out var typeEl) ? typeEl.GetString() : null; + + if (scriptPubKey.TryGetProperty("address", out var addrEl)) + { + output.Address = addrEl.GetString(); + } + else if (scriptPubKey.TryGetProperty("addresses", out var addrsEl) && addrsEl.GetArrayLength() > 0) + { + output.Address = addrsEl[0].GetString(); + } + } + + outputs.Add(output); + } + } + + return new QueryTransaction + { + TransactionId = txId, + Version = version, + LockTime = locktime.ToString(), + Size = size, + VirtualSize = vsize, + Weight = weight, + BlockHash = blockHash, + BlockIndex = blockHeight, + Timestamp = timestamp, + Inputs = inputs, + Outputs = outputs + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to map Electrum transaction to QueryTransaction"); + return null; + } + } + + #endregion +} + +#region Electrum Response Models + +/// +/// Response from blockchain.scripthash.get_balance +/// +internal class ElectrumBalanceResponse +{ + public long Confirmed { get; set; } + public long Unconfirmed { get; set; } +} + +/// +/// Response item from blockchain.scripthash.listunspent +/// +internal class ElectrumUtxoResponse +{ + public int Height { get; set; } + public string TxHash { get; set; } = string.Empty; + public int TxPos { get; set; } + public long Value { get; set; } +} + +/// +/// Response item from blockchain.scripthash.get_history +/// +internal class ElectrumHistoryItemResponse +{ + public int Height { get; set; } + public string TxHash { get; set; } = string.Empty; + public long Fee { get; set; } +} + +#endregion diff --git a/src/Angor/Shared/Services/Indexer/Electrum/ElectrumScriptHashUtility.cs b/src/Angor/Shared/Services/Indexer/Electrum/ElectrumScriptHashUtility.cs new file mode 100644 index 000000000..80883fb50 --- /dev/null +++ b/src/Angor/Shared/Services/Indexer/Electrum/ElectrumScriptHashUtility.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using Blockcore.Consensus.ScriptInfo; +using Blockcore.NBitcoin; +using Blockcore.NBitcoin.DataEncoders; +using Blockcore.Networks; + +namespace Angor.Shared.Services.Indexer.Electrum; + +public static class ElectrumScriptHashUtility +{ + public static string AddressToScriptHash(string address, Network network) + { + var bitcoinAddress = BitcoinAddress.Create(address, network); + var scriptPubKey = bitcoinAddress.ScriptPubKey; + return ScriptToScriptHash(scriptPubKey); + } + + public static string ScriptToScriptHash(Script script) + { + return ScriptToScriptHash(script.ToBytes()); + } + + public static string ScriptToScriptHash(byte[] scriptBytes) + { + var hash = SHA256.HashData(scriptBytes); + // Electrum uses reversed byte order (little-endian) + Array.Reverse(hash); + return Encoders.Hex.EncodeData(hash); + } + + public static string ScriptHexToScriptHash(string scriptHex) + { + var scriptBytes = Encoders.Hex.DecodeData(scriptHex); + return ScriptToScriptHash(scriptBytes); + } +} + +public static class ElectrumExtensions +{ + public static string ToElectrumScriptHash(this BitcoinAddress address) + { + return ElectrumScriptHashUtility.ScriptToScriptHash(address.ScriptPubKey); + } + + public static string ToElectrumScriptHash(this Script script) + { + return ElectrumScriptHashUtility.ScriptToScriptHash(script); + } +} diff --git a/src/Angor/Shared/Services/Indexer/Electrum/ElectrumServiceExtensions.cs b/src/Angor/Shared/Services/Indexer/Electrum/ElectrumServiceExtensions.cs new file mode 100644 index 000000000..322cae15a --- /dev/null +++ b/src/Angor/Shared/Services/Indexer/Electrum/ElectrumServiceExtensions.cs @@ -0,0 +1,98 @@ +using Angor.Shared.Services.Indexer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Angor.Shared.Services.Indexer.Electrum; + +/// +/// Extension methods for registering Electrum services in DI container. +/// +public static class ElectrumServiceExtensions +{ + /// + /// Adds Electrum-based indexer services to the service collection. + /// This replaces the HTTP-based MempoolSpaceIndexerApi with Electrum protocol services. + /// + /// The service collection. + /// Optional list of Electrum server configurations. If not provided, uses default configurations. + /// The service collection for chaining. + public static IServiceCollection AddElectrumServices( + this IServiceCollection services, + IEnumerable? serverConfigs = null) + { + // Use default servers if none provided + var configs = serverConfigs?.ToList() ?? GetDefaultServerConfigs(); + + // Register the client pool as singleton (manages connections) + services.AddSingleton(sp => + { + var logger = sp.GetRequiredService>(); + var loggerFactory = sp.GetRequiredService(); + return new ElectrumClientPool(logger, loggerFactory, configs); + }); + + // Register Electrum-based IIndexerService + services.AddSingleton(); + + // Register Electrum-based IAngorIndexerService + services.AddSingleton(); + + // Ensure MempoolIndexerMappers is registered (needed by ElectrumAngorIndexerService) + services.TryAddSingleton(); + + return services; + } + + /// + /// Adds Electrum services configured with a single server. + /// + /// The service collection. + /// Electrum server hostname. + /// Electrum server port (default: 50002 for SSL). + /// Whether to use SSL (default: true). + /// The service collection for chaining. + public static IServiceCollection AddElectrumServices( + this IServiceCollection services, + string host, + int port = 50002, + bool useSsl = true) + { + var config = new ElectrumServerConfig + { + Host = host, + Port = port, + UseSsl = useSsl + }; + + return services.AddElectrumServices(new[] { config }); + } + + /// + /// Gets default Electrum server configurations for common networks. + /// + private static List GetDefaultServerConfigs() + { + // These are well-known public Electrum servers + // Users should configure their own servers for production use + return new List + { + // Blockstream's Electrum server (mainnet) + new() + { + Host = "electrum.blockstream.info", + Port = 50002, + UseSsl = true, + Network = "mainnet" + }, + // Blockstream's Electrum server (testnet) + new() + { + Host = "electrum.blockstream.info", + Port = 60002, + UseSsl = true, + Network = "testnet" + } + }; + } +} diff --git a/src/Angor/Shared/Services/IAngorIndexerService.cs b/src/Angor/Shared/Services/Indexer/IAngorIndexerService.cs similarity index 92% rename from src/Angor/Shared/Services/IAngorIndexerService.cs rename to src/Angor/Shared/Services/Indexer/IAngorIndexerService.cs index f676ce321..4bbe06526 100644 --- a/src/Angor/Shared/Services/IAngorIndexerService.cs +++ b/src/Angor/Shared/Services/Indexer/IAngorIndexerService.cs @@ -1,6 +1,6 @@ using Angor.Shared.Models; -namespace Angor.Shared.Services; +namespace Angor.Shared.Services.Indexer; public interface IAngorIndexerService { diff --git a/src/Angor/Shared/Services/IIndexerService.cs b/src/Angor/Shared/Services/Indexer/IIndexerService.cs similarity index 95% rename from src/Angor/Shared/Services/IIndexerService.cs rename to src/Angor/Shared/Services/Indexer/IIndexerService.cs index 8d0cc49ec..6539c8776 100644 --- a/src/Angor/Shared/Services/IIndexerService.cs +++ b/src/Angor/Shared/Services/Indexer/IIndexerService.cs @@ -1,6 +1,6 @@ using Angor.Shared.Models; -namespace Angor.Shared.Services; +namespace Angor.Shared.Services.Indexer; public interface IIndexerService { diff --git a/src/Angor/Shared/Services/IndexerService.cs b/src/Angor/Shared/Services/Indexer/IndexerService.cs similarity index 99% rename from src/Angor/Shared/Services/IndexerService.cs rename to src/Angor/Shared/Services/Indexer/IndexerService.cs index 5464856ff..8eb082989 100644 --- a/src/Angor/Shared/Services/IndexerService.cs +++ b/src/Angor/Shared/Services/Indexer/IndexerService.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging; -namespace Angor.Shared.Services +namespace Angor.Shared.Services.Indexer { [Obsolete("This class is deprecated and will be removed in future versions. Please use the new MempoolSpaceIndexerApi instead.")] diff --git a/src/Angor/Shared/Services/MempoolIndexerAngorApi.cs b/src/Angor/Shared/Services/Indexer/MempoolIndexerAngorApi.cs similarity index 99% rename from src/Angor/Shared/Services/MempoolIndexerAngorApi.cs rename to src/Angor/Shared/Services/Indexer/MempoolIndexerAngorApi.cs index dfd39cc72..8fb971a43 100644 --- a/src/Angor/Shared/Services/MempoolIndexerAngorApi.cs +++ b/src/Angor/Shared/Services/Indexer/MempoolIndexerAngorApi.cs @@ -4,7 +4,7 @@ using Angor.Shared.Models; using Microsoft.Extensions.Logging; -namespace Angor.Shared.Services; +namespace Angor.Shared.Services.Indexer; public class MempoolIndexerAngorApi : IAngorIndexerService { diff --git a/src/Angor/Shared/Services/MempoolIndexerMappers.cs b/src/Angor/Shared/Services/Indexer/MempoolIndexerMappers.cs similarity index 99% rename from src/Angor/Shared/Services/MempoolIndexerMappers.cs rename to src/Angor/Shared/Services/Indexer/MempoolIndexerMappers.cs index be2c4a8d4..e744807f8 100644 --- a/src/Angor/Shared/Services/MempoolIndexerMappers.cs +++ b/src/Angor/Shared/Services/Indexer/MempoolIndexerMappers.cs @@ -2,7 +2,7 @@ using Blockcore.NBitcoin.DataEncoders; using Microsoft.Extensions.Logging; -namespace Angor.Shared.Services; +namespace Angor.Shared.Services.Indexer; public class MempoolIndexerMappers { diff --git a/src/Angor/Shared/Services/MempoolMonitoringService.cs b/src/Angor/Shared/Services/Indexer/MempoolMonitoringService.cs similarity index 99% rename from src/Angor/Shared/Services/MempoolMonitoringService.cs rename to src/Angor/Shared/Services/Indexer/MempoolMonitoringService.cs index d72d69c17..f4551fa15 100644 --- a/src/Angor/Shared/Services/MempoolMonitoringService.cs +++ b/src/Angor/Shared/Services/Indexer/MempoolMonitoringService.cs @@ -1,7 +1,7 @@ using Angor.Shared.Models; using Microsoft.Extensions.Logging; -namespace Angor.Shared.Services; +namespace Angor.Shared.Services.Indexer; public class MempoolMonitoringService : IMempoolMonitoringService { diff --git a/src/Angor/Shared/Services/MempoolSpaceIndexerApi.cs b/src/Angor/Shared/Services/Indexer/MempoolSpaceIndexerApi.cs similarity index 99% rename from src/Angor/Shared/Services/MempoolSpaceIndexerApi.cs rename to src/Angor/Shared/Services/Indexer/MempoolSpaceIndexerApi.cs index 65f409998..789635a75 100644 --- a/src/Angor/Shared/Services/MempoolSpaceIndexerApi.cs +++ b/src/Angor/Shared/Services/Indexer/MempoolSpaceIndexerApi.cs @@ -9,7 +9,7 @@ using Blockcore.NBitcoin.DataEncoders; using Microsoft.Extensions.Logging; -namespace Angor.Shared.Services; +namespace Angor.Shared.Services.Indexer; public class MempoolSpaceIndexerApi : IIndexerService { diff --git a/src/Angor/Shared/WalletOperations.cs b/src/Angor/Shared/WalletOperations.cs index 95ef2a2ac..ff8299270 100644 --- a/src/Angor/Shared/WalletOperations.cs +++ b/src/Angor/Shared/WalletOperations.cs @@ -1,5 +1,5 @@ using Angor.Shared.Models; -using Angor.Shared.Services; +using Angor.Shared.Services.Indexer; using Angor.Shared.Utilities; using Blockcore.Consensus.ScriptInfo; using Blockcore.Consensus.TransactionInfo;