diff --git a/faucet-api/Models/BitcoinSettings.cs b/faucet-api/Models/BitcoinSettings.cs index 5a110ed..0ddf88e 100644 --- a/faucet-api/Models/BitcoinSettings.cs +++ b/faucet-api/Models/BitcoinSettings.cs @@ -1,6 +1,7 @@ public class BitcoinSettings { + public string Indexer { get; set; } public string IndexerUrl { get; set; } public string Mnemonic { get; set; } public string Network { get; set; } diff --git a/faucet-api/Models/MempoolModels.cs b/faucet-api/Models/MempoolModels.cs new file mode 100644 index 0000000..29e4e0f --- /dev/null +++ b/faucet-api/Models/MempoolModels.cs @@ -0,0 +1,93 @@ +public class AddressStats +{ + public int FundedTxoCount { get; set; } + public long FundedTxoSum { get; set; } + public int SpentTxoCount { get; set; } + public long SpentTxoSum { get; set; } + public int TxCount { get; set; } +} +public class AddressResponse +{ + public string Address { get; set; } + public AddressStats ChainStats { get; set; } + public AddressStats MempoolStats { get; set; } +} + +public class OutspentResponse +{ + public bool Spent { get; set; } + public string Txid { get; set; } + public int Vin { get; set; } + public UtxoStatus Status { get; set; } + +} + +public class AddressUtxo +{ + public string Txid { get; set; } + public int Vout { get; set; } + public UtxoStatus Status { get; set; } + public long Value { get; set; } +} + +public class UtxoStatus +{ + public bool Confirmed { get; set; } + public int BlockHeight { get; set; } + public string BlockHash { get; set; } + public long BlockTime { get; set; } +} + +public class RecommendedFees +{ + public int FastestFee { get; set; } + public int HalfHourFee { get; set; } + public int HourFee { get; set; } + public int EconomyFee { get; set; } + public int MinimumFee { get; set; } +} + +public class Vin +{ + public bool IsCoinbase { get; set; } + public PrevOut 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; } + public string InnserRedeemscriptAsm { get; set; } + public string InnerWitnessscriptAsm { get; set; } +} +public class PrevOut +{ + 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; } +} + +public class MempoolTransaction +{ + public string Txid { get; set; } + + 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 List Vin { get; set; } + public List Vout { get; set; } + public UtxoStatus Status { get; set; } +} + +public class Outspent +{ + public bool Spent { get; set; } + public string Txid { get; set; } + public int Vin { get; set; } + public UtxoStatus Status { get; set; } +} \ No newline at end of file diff --git a/faucet-api/Program.cs b/faucet-api/Program.cs index 1ae6997..22c28af 100644 --- a/faucet-api/Program.cs +++ b/faucet-api/Program.cs @@ -8,15 +8,32 @@ builder.Services.AddControllers(); -builder.Services.AddHttpClient(client => +var bitcoinSettings = new BitcoinSettings(); +builder.Configuration.GetSection("Bitcoin").Bind(bitcoinSettings); +if (bitcoinSettings.Indexer == "Mempool") { - var indexerUrl = builder.Configuration.GetSection("Bitcoin")["IndexerUrl"]; - if (string.IsNullOrEmpty(indexerUrl)) + builder.Services.AddHttpClient(client => { - throw new ArgumentException("IndexerUrl is not configured in appsettings.json."); - } - client.BaseAddress = new Uri(indexerUrl); -}); + var indexerUrl = builder.Configuration.GetSection("Bitcoin")["IndexerUrl"]; + if (string.IsNullOrEmpty(indexerUrl)) + { + throw new ArgumentException("IndexerUrl is not configured in appsettings.json."); + } + client.BaseAddress = new Uri(indexerUrl); + }); +} +else +{ + builder.Services.AddHttpClient(client => + { + var indexerUrl = builder.Configuration.GetSection("Bitcoin")["IndexerUrl"]; + if (string.IsNullOrEmpty(indexerUrl)) + { + throw new ArgumentException("IndexerUrl is not configured in appsettings.json."); + } + client.BaseAddress = new Uri(indexerUrl); + }); +} builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => diff --git a/faucet-api/Services/MempoolService.cs b/faucet-api/Services/MempoolService.cs new file mode 100644 index 0000000..48d7a48 --- /dev/null +++ b/faucet-api/Services/MempoolService.cs @@ -0,0 +1,338 @@ + +using System.Text.Json; +using Microsoft.Extensions.Options; +using NBitcoin; +using NBitcoin.DataEncoders; + +namespace BitcoinFaucetApi.Services +{ + public class MempoolService : IIndexerService + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _indexerUrl; + private string MempoolApiRoute = "/api"; + public MempoolService(HttpClient httpClient, ILogger logger, IOptions bitcoinSettings) + { + _httpClient = httpClient; + _logger = logger; + if (string.IsNullOrEmpty(bitcoinSettings.Value.IndexerUrl)) + { + throw new ArgumentException("IndexerUrl is not configured in appsettings.json."); + } + _indexerUrl = bitcoinSettings.Value.IndexerUrl.TrimEnd('/'); + } + public async Task<(bool IsOnline, string? GenesisHash)> CheckIndexerNetwork() + { + try + { + var url = $"{_indexerUrl}{MempoolApiRoute}/block-height/0"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning($"Failed to fetch genesis block from: {url}"); + return (false, null); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var blockData = JsonSerializer.Deserialize(responseContent); + + if (blockData.TryGetProperty("blockHash", out var blockHashElement)) + { + return (true, blockHashElement.GetString()); + } + + _logger.LogWarning("blockHash not found in the response."); + return (true, null); + } + catch (Exception ex) + { + _logger.LogError($"Error during indexer network check: {ex.Message}"); + return (false, null); + } + } + + public async Task?> FetchUtxoAsync(string address, int offset, int limit) + { + var txsUrl = $"{_indexerUrl}{MempoolApiRoute}/address/{address}/txs"; + + var response = await _httpClient.GetAsync(txsUrl); + + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException(response.ReasonPhrase); + + var trx = await response.Content.ReadFromJsonAsync>(new JsonSerializerOptions() + { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); + + var utxoDataList = new List(); + + foreach (var mempoolTransaction in trx) + { + if (mempoolTransaction.Vout.All(v => v.ScriptpubkeyAddress != address)) + { + // this trx has no outputs with the requested address. + continue; + } + + var outspendsUrl = $"{MempoolApiRoute}/tx/" + mempoolTransaction.Txid + "/outspends"; + + var resultsOutputs = await _httpClient.GetAsync(_indexerUrl + outspendsUrl); + + var spentOutputsStatus = await resultsOutputs.Content.ReadFromJsonAsync>(new JsonSerializerOptions() + { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); + + for (int index = 0; index < mempoolTransaction.Vout.Count; index++) + { + var vout = mempoolTransaction.Vout[index]; + + if (vout.ScriptpubkeyAddress == address) + { + if (mempoolTransaction.Status.Confirmed && spentOutputsStatus![index].Spent) + { + continue; + } + + var data = new UtxoData + { + address = vout.ScriptpubkeyAddress, + scriptHex = vout.Scriptpubkey, + outpoint = new Outpoint(mempoolTransaction.Txid, index), + value = vout.Value, + }; + + if (mempoolTransaction.Status.Confirmed) + { + data.blockIndex = mempoolTransaction.Status.BlockHeight; + } + + if (spentOutputsStatus![index].Spent) + { + data.PendingSpent = true; + } + + utxoDataList.Add(data); + } + } + } + + return utxoDataList; + } + + public async Task GetAdressBalancesAsync(List data, bool includeUnconfirmed = false) + { + var urlBalance = $"{MempoolApiRoute}/address/"; + + var tasks = data.Select(x => + { + return _httpClient.GetAsync(_indexerUrl + urlBalance + x.Address); + }); + + var results = await Task.WhenAll(tasks); + + var response = new List(); + + foreach (var apiResponse in results) + { + if (!apiResponse.IsSuccessStatusCode) + throw new InvalidOperationException(apiResponse.ReasonPhrase); + + var addressResponse = await apiResponse.Content.ReadFromJsonAsync(new JsonSerializerOptions() + { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); + + if (addressResponse != null && (addressResponse.ChainStats.TxCount > 0 || addressResponse.MempoolStats.TxCount > 0)) + { + response.Add(new AddressBalance + { + address = addressResponse.Address, + balance = addressResponse.ChainStats.FundedTxoSum - addressResponse.ChainStats.SpentTxoSum, + pendingReceived = addressResponse.MempoolStats.FundedTxoSum - addressResponse.MempoolStats.SpentTxoSum + }); + } + } + + return response.ToArray(); + } + + public async Task GetFeeEstimationAsync(int[] confirmations) + { + var url = $"{MempoolApiRoute}/fees/recommended"; + + var response = await _httpClient.GetAsync(_indexerUrl + url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError($"Error code {response.StatusCode}, {response.ReasonPhrase}"); + return null; + } + + var feeEstimations = await response.Content.ReadFromJsonAsync(new JsonSerializerOptions() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + return new FeeEstimations + { + Fees = new List + { + new() { FeeRate = feeEstimations.FastestFee * 1100, Confirmations = 1 }, //TODO this is an estimation + new() { FeeRate = feeEstimations.HalfHourFee * 1100, Confirmations = 3 }, + new() { FeeRate = feeEstimations.HourFee * 1100, Confirmations = 6 }, + new() { FeeRate = feeEstimations.EconomyFee * 1100, Confirmations = 18 }, //TODO this is an estimation + } + }; + } + + public async Task GetTransactionHexByIdAsync(string transactionId) + { + var url = $"{MempoolApiRoute}/tx/{transactionId}/hex"; + + var response = await _httpClient.GetAsync(_indexerUrl + url); + + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException(response.ReasonPhrase); + + return await response.Content.ReadAsStringAsync(); + } + + public async Task GetTransactionInfoByIdAsync(string transactionId) + { + var url = $"{MempoolApiRoute}/tx/{transactionId}"; + + var response = await _httpClient.GetAsync(_indexerUrl + url); + + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException(response.ReasonPhrase); + + var options = new JsonSerializerOptions() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + var trx = await response.Content.ReadFromJsonAsync(options); + + var urlSpent = $"{MempoolApiRoute}/tx/{transactionId}/outspends"; + + var responseSpent = await _httpClient.GetAsync(_indexerUrl + urlSpent); + + if (!responseSpent.IsSuccessStatusCode) + throw new InvalidOperationException(responseSpent.ReasonPhrase); + + var spends = await responseSpent.Content.ReadFromJsonAsync>(options); + + await PopulateSpentMissingData(spends, trx); + + return MapToQueryTransaction(trx, spends); + } + private QueryTransaction MapToQueryTransaction(MempoolTransaction x, List? spends = null) + { + return new QueryTransaction + { + BlockHash = x.Status.BlockHash, + BlockIndex = x.Status.BlockHeight, + Size = x.Size, + // Confirmations = null, + Fee = x.Fee, + // HasWitness = null, + Inputs = x.Vin.Select((vin, i) => new QueryTransactionInput + { + // CoinBase = null, + InputAddress = vin.Prevout.ScriptpubkeyAddress, + InputAmount = vin.Prevout.Value, + InputIndex = vin.Vout, + InputTransactionId = vin.Txid, + WitScript = new WitScript(vin.Witness.Select(s => Encoders.Hex.DecodeData(s)).ToArray()).ToScript() + .ToHex(), + SequenceLock = vin.Sequence.ToString(), + ScriptSig = vin.Scriptsig, + ScriptSigAsm = vin.Asm + }).ToList(), + LockTime = x.Locktime.ToString(), + Outputs = x.Vout.Select((vout, i) => + new QueryTransactionOutput + { + Address = vout.ScriptpubkeyAddress, + Balance = vout.Value, + Index = i, + ScriptPubKey = vout.Scriptpubkey, + OutputType = vout.ScriptpubkeyType, + ScriptPubKeyAsm = vout.ScriptpubkeyAsm, + SpentInTransaction = spends?.ElementAtOrDefault(i)?.Txid ?? string.Empty + }).ToList(), + Timestamp = x.Status.BlockTime, + TransactionId = x.Txid, + TransactionIndex = null, + Version = (uint)x.Version, + VirtualSize = x.Size, + Weight = x.Weight + }; + } + private async Task PopulateSpentMissingData(List outspents, MempoolTransaction mempoolTransaction) + { + for (int index = 0; index < outspents.Count; index++) + { + var outspent = outspents[index]; + + if (outspent.Spent && outspent.Txid == null) + { + var output = mempoolTransaction.Vout[index]; + if (output != null && !string.IsNullOrEmpty(output.ScriptpubkeyAddress)) + { + var txsUrl = $"{MempoolApiRoute}/address/{output.ScriptpubkeyAddress}/txs"; + + var response = await _httpClient.GetAsync(_indexerUrl + txsUrl); + + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException(response.ReasonPhrase); + + var trx = await response.Content.ReadFromJsonAsync>(new JsonSerializerOptions() + { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); + + bool found = false; + foreach (var transaction in trx) + { + var vinIndex = 0; + foreach (var vin in transaction.Vin) + { + if (vin.Txid == mempoolTransaction.Txid && vin.Vout == index) + { + outspent.Txid = transaction.Txid; + outspent.Vin = vinIndex; + + found = true; + break; + } + + vinIndex++; + } + + if (found) break; + } + } + } + } + } + + public async Task PublishTransactionAsync(string trxHex) + { + var response = await _httpClient.PostAsync($"{_indexerUrl}{MempoolApiRoute}/tx", new StringContent(trxHex)); + + if (response.IsSuccessStatusCode) + { + var txId = await response.Content.ReadAsStringAsync(); //The txId + _logger.LogInformation("trx " + txId + "posted "); + return string.Empty; + } + + var content = await response.Content.ReadAsStringAsync(); + + return response.ReasonPhrase + content; + } + + public bool ValidateGenesisBlockHash(string fetchedHash, string expectedHash) + { + return fetchedHash.StartsWith(expectedHash, StringComparison.OrdinalIgnoreCase) || string.IsNullOrEmpty(fetchedHash); + } + } +} \ No newline at end of file diff --git a/faucet-api/appsettings.json b/faucet-api/appsettings.json index 022a290..06e174b 100644 --- a/faucet-api/appsettings.json +++ b/faucet-api/appsettings.json @@ -1,6 +1,6 @@ { "Bitcoin": { - "Mnemonic": "", + "Indexer": "Blockcore", "Network": "TestNet", "IndexerUrl": "https://test.indexer.angor.io", "FeeRate": 10001,