diff --git a/NBXplorer.Client/ExplorerClient.cs b/NBXplorer.Client/ExplorerClient.cs index fb9f479dd..3f5f302f0 100644 --- a/NBXplorer.Client/ExplorerClient.cs +++ b/NBXplorer.Client/ExplorerClient.cs @@ -366,22 +366,32 @@ public Task GetStatusAsync(CancellationToken cancellation = defaul { return SendAsync(HttpMethod.Get, null, $"v1/cryptos/{CryptoCode}/status", cancellation); } - public GetTransactionsResponse GetTransactions(DerivationStrategyBase strategy, CancellationToken cancellation = default) + public GetTransactionsResponse GetTransactions(DerivationStrategyBase strategy, DateTimeOffset? from = null, DateTimeOffset? to = null, CancellationToken cancellation = default) { - return GetTransactionsAsync(strategy, cancellation).GetAwaiter().GetResult(); + return GetTransactionsAsync(strategy, from, to, cancellation).GetAwaiter().GetResult(); } - public GetTransactionsResponse GetTransactions(TrackedSource trackedSource, CancellationToken cancellation = default) + public GetTransactionsResponse GetTransactions(TrackedSource trackedSource, DateTimeOffset? from = null, DateTimeOffset? to = null, CancellationToken cancellation = default) { - return GetTransactionsAsync(trackedSource, cancellation).GetAwaiter().GetResult(); + return GetTransactionsAsync(trackedSource, from, to, cancellation).GetAwaiter().GetResult(); } - public Task GetTransactionsAsync(DerivationStrategyBase strategy, CancellationToken cancellation = default) + public Task GetTransactionsAsync(DerivationStrategyBase strategy, DateTimeOffset? from = null, DateTimeOffset? to = null, CancellationToken cancellation = default) { - return GetTransactionsAsync(TrackedSource.Create(strategy), cancellation); + return GetTransactionsAsync(TrackedSource.Create(strategy), from, to, cancellation); } - public Task GetTransactionsAsync(TrackedSource trackedSource, CancellationToken cancellation = default) + public Task GetTransactionsAsync(TrackedSource trackedSource, DateTimeOffset? from = null, DateTimeOffset? to = null, CancellationToken cancellation = default) { - return SendAsync(HttpMethod.Get, null, $"{GetBasePath(trackedSource)}/transactions", cancellation); + string fromV = string.Empty; + string toV = string.Empty; + if (from is DateTimeOffset f) + { + fromV = NBitcoin.Utils.DateTimeToUnixTime(f).ToString(); + } + if (to is DateTimeOffset t) + { + toV = NBitcoin.Utils.DateTimeToUnixTime(t).ToString(); + } + return SendAsync(HttpMethod.Get, null, $"{GetBasePath(trackedSource)}/transactions?from={fromV}&to={toV}", cancellation); } diff --git a/NBXplorer.Client/NBXplorer.Client.csproj b/NBXplorer.Client/NBXplorer.Client.csproj index a471e56b9..0953accbf 100644 --- a/NBXplorer.Client/NBXplorer.Client.csproj +++ b/NBXplorer.Client/NBXplorer.Client.csproj @@ -3,7 +3,7 @@ netstandard2.1 Digital Garage - 4.3.4 + 4.3.5 Copyright © Digital Garage 2017 Client API for the minimalist HD Wallet Tracker NBXplorer Bitcoin.png diff --git a/NBXplorer.Tests/UnitTest1.cs b/NBXplorer.Tests/UnitTest1.cs index c91ee601e..ef6001033 100644 --- a/NBXplorer.Tests/UnitTest1.cs +++ b/NBXplorer.Tests/UnitTest1.cs @@ -2211,7 +2211,7 @@ public async Task DoNotLoseTimestampForLongConfirmations() var id = tester.SendToAddress(tester.AddressOf(bob, "0/1"), Money.Coins(1.0m)); tester.Notifications.WaitForTransaction(bobPubKey, id); var repo = tester.GetService().GetRepository(tester.Network.NetworkSet.CryptoCode); - var transactions = await repo.GetTransactions(new DerivationSchemeTrackedSource(bobPubKey), id); + var transactions = await repo.GetTransactions(GetTransactionQuery.Create(new DerivationSchemeTrackedSource(bobPubKey), id)); var tx = Assert.Single(transactions); var timestamp = tx.FirstSeen; var query = MatchQuery.FromTransactions(new[] { tx.Transaction }, null); @@ -2219,7 +2219,7 @@ public async Task DoNotLoseTimestampForLongConfirmations() var tracked = await repo.SaveMatches(query, records); Assert.Single(tracked); Assert.Equal(timestamp, tracked[0].FirstSeen); - transactions = await repo.GetTransactions(new DerivationSchemeTrackedSource(bobPubKey), id); + transactions = await repo.GetTransactions(GetTransactionQuery.Create(new DerivationSchemeTrackedSource(bobPubKey), id)); tx = Assert.Single(transactions); Assert.Equal(timestamp, tx.FirstSeen); } @@ -2523,6 +2523,16 @@ public async Task CanTrackAddress() var tx = await tester.Client.GetTransactionsAsync(addressSource); Assert.Equal(tx1, tx.ConfirmedTransactions.Transactions[0].TransactionId); + Logs.Tester.LogInformation("Check from/to"); + var beforeTx = tx.ConfirmedTransactions.Transactions[0].Timestamp - TimeSpan.FromSeconds(5.0); + var afterTx = tx.ConfirmedTransactions.Transactions[0].Timestamp + TimeSpan.FromSeconds(5.0); + tx = await tester.Client.GetTransactionsAsync(addressSource, from: beforeTx, to: afterTx); + Assert.Equal(tx1, tx.ConfirmedTransactions.Transactions[0].TransactionId); + tx = await tester.Client.GetTransactionsAsync(addressSource, from: afterTx); + Assert.Empty(tx.ConfirmedTransactions.Transactions); + tx = await tester.Client.GetTransactionsAsync(addressSource, to: beforeTx); + Assert.Empty(tx.ConfirmedTransactions.Transactions); + tx = await tester.Client.GetTransactionsAsync(pubkey); Assert.Equal(tx1, tx.ConfirmedTransactions.Transactions[0].TransactionId); diff --git a/NBXplorer/Backend/GetTransactionQuery.cs b/NBXplorer/Backend/GetTransactionQuery.cs index e85b6b644..db0ecbc07 100644 --- a/NBXplorer/Backend/GetTransactionQuery.cs +++ b/NBXplorer/Backend/GetTransactionQuery.cs @@ -10,27 +10,38 @@ namespace NBXplorer.Backend { public abstract record GetTransactionQuery { - public static GetTransactionQuery Create(TrackedSource TrackedSource, uint256 TxId) => new TrackedSourceTxId(TrackedSource, TxId); - public static GetTransactionQuery Create(KeyPathInformation[] KeyInfos, uint256[] TxIds) => new ScriptsTxIds(KeyInfos, TxIds); - public record TrackedSourceTxId(TrackedSource TrackedSource, uint256? TxId) : GetTransactionQuery + public static TrackedSourceTxId Create(TrackedSource TrackedSource, uint256? TxId = null, DateTimeOffset? from = null, DateTimeOffset? to = null) => new TrackedSourceTxId(TrackedSource, TxId, from, to); + public static ScriptsTxIds Create(KeyPathInformation[] KeyInfos, uint256[] TxIds) => new ScriptsTxIds(KeyInfos, TxIds); + public record TrackedSourceTxId(TrackedSource TrackedSource, uint256? TxId, DateTimeOffset? From, DateTimeOffset? To) : GetTransactionQuery { string? walletId; public override string GetSql(DynamicParameters parameters, NBXplorerNetwork network) { - var txIdCond = string.Empty; + string txIdCond = String.Empty, fromCond = String.Empty, toCond = String.Empty; if (TxId is not null) { txIdCond = " AND tx_id=@tx_id"; parameters.Add("@tx_id", TxId.ToString()); } + if (From is DateTimeOffset f) + { + fromCond = " AND @from <= seen_at"; + parameters.Add("@from", f); + } + if (To is DateTimeOffset t) + { + toCond = " AND seen_at <= @to"; + parameters.Add("@to", t); + } walletId = Repository.GetWalletKey(TrackedSource, network).wid; parameters.Add("@walletId", walletId); parameters.Add("@code", network.CryptoCode); - return """ + + return $""" SELECT wallet_id, tx_id, idx, blk_id, blk_height, blk_idx, is_out, spent_tx_id, spent_idx, script, s.addr, value, asset_id, immature, keypath, seen_at FROM nbxv1_tracked_txs LEFT JOIN scripts s USING (code, script) - WHERE code=@code AND wallet_id=@walletId - """ + txIdCond; + WHERE code=@code AND wallet_id=@walletId{txIdCond}{fromCond}{toCond} + """; } public override TrackedSource? GetTrackedSource(string wallet_id) => walletId == wallet_id ? TrackedSource : null; } diff --git a/NBXplorer/Backend/Repository.cs b/NBXplorer/Backend/Repository.cs index 7d2481d69..f5ddd485a 100644 --- a/NBXplorer/Backend/Repository.cs +++ b/NBXplorer/Backend/Repository.cs @@ -677,10 +677,6 @@ public async Task GetSavedTransactions(uint256 txid) ReplacedBy = tx.replaced_by is null ? null : uint256.Parse(tx.replaced_by) }}; } - - - public Task GetTransactions(TrackedSource trackedSource, uint256 txId = null, bool includeTransactions = true, CancellationToken cancellation = default) => - GetTransactions(GetTransactionQuery.Create(trackedSource, txId), includeTransactions, cancellation); public async Task GetTransactions(GetTransactionQuery query, bool includeTransactions = true, CancellationToken cancellation = default) { await using var connection = await connectionFactory.CreateConnectionHelper(Network); diff --git a/NBXplorer/Controllers/DerivationSchemesController.cs b/NBXplorer/Controllers/DerivationSchemesController.cs index 2460704f1..f6e739321 100644 --- a/NBXplorer/Controllers/DerivationSchemesController.cs +++ b/NBXplorer/Controllers/DerivationSchemesController.cs @@ -99,7 +99,7 @@ public async Task Wipe(TrackedSourceContext trackedSourceContext) { var repo = trackedSourceContext.Repository; var ts = trackedSourceContext.TrackedSource; - var txs = await repo.GetTransactions(trackedSourceContext.TrackedSource); + var txs = await repo.GetTransactions(GetTransactionQuery.Create(trackedSourceContext.TrackedSource)); await repo.Prune(txs); return Ok(); } @@ -149,7 +149,7 @@ public async Task Prune(TrackedSourceContext trackedSourceContext var network = trackedSourceContext.Network; var repo = trackedSourceContext.Repository; - var transactions = await MainController.GetAnnotatedTransactions(repo, trackedSource, false); + var transactions = await MainController.GetAnnotatedTransactions(repo, GetTransactionQuery.Create(trackedSource), false); var state = transactions.ConfirmedState; var prunableIds = new HashSet(); diff --git a/NBXplorer/Controllers/MainController.PSBT.cs b/NBXplorer/Controllers/MainController.PSBT.cs index 9082bf7b8..7746999b8 100644 --- a/NBXplorer/Controllers/MainController.PSBT.cs +++ b/NBXplorer/Controllers/MainController.PSBT.cs @@ -558,7 +558,7 @@ private async Task UpdateUTXO(UpdatePSBTRequest update, Repository repo, RPCClie // First, we check for data in our history foreach (var input in update.PSBT.Inputs.Where(psbtInput => NeedUTXO(update, psbtInput))) { - txs = txs ?? await GetAnnotatedTransactions(repo, new DerivationSchemeTrackedSource(derivationScheme), NeedNonWitnessUTXO(update, input)); + txs = txs ?? await GetAnnotatedTransactions(repo, GetTransactionQuery.Create(TrackedSource.Create(derivationScheme)), NeedNonWitnessUTXO(update, input)); if (txs.GetByTxId(input.PrevOut.Hash) is AnnotatedTransaction tx) { if (!tx.Record.Key.IsPruned) diff --git a/NBXplorer/Controllers/MainController.cs b/NBXplorer/Controllers/MainController.cs index 74775062f..621f39be9 100644 --- a/NBXplorer/Controllers/MainController.cs +++ b/NBXplorer/Controllers/MainController.cs @@ -335,7 +335,6 @@ public async Task GetStatus(string cryptoCode) [Route($"{CommonRoutes.BaseCryptoEndpoint}/connect")] public async Task ConnectWebSocket( string cryptoCode, - bool includeTransaction = true, CancellationToken cancellation = default) { if (!HttpContext.WebSockets.IsWebSocketRequest) @@ -550,7 +549,11 @@ public async Task GetTransactions( TrackedSourceContext trackedSourceContext, [ModelBinder(BinderType = typeof(UInt256ModelBinding))] uint256 txId = null, - bool includeTransaction = true) + bool includeTransaction = true, + [ModelBinder(BinderType = typeof(DateTimeOffsetModelBinder))] + DateTimeOffset? from = null, + [ModelBinder(BinderType = typeof(DateTimeOffsetModelBinder))] + DateTimeOffset? to = null) { TransactionInformation fetchedTransactionInfo = null; var network = trackedSourceContext.Network; @@ -560,7 +563,8 @@ public async Task GetTransactions( var response = new GetTransactionsResponse(); int currentHeight = (await repo.GetTip()).Height; response.Height = currentHeight; - var txs = await GetAnnotatedTransactions(repo, trackedSource, includeTransaction, txId); + var query = GetTransactionQuery.Create(trackedSource, txId, from, to); + var txs = await GetAnnotatedTransactions(repo, query, includeTransaction); foreach (var item in new[] { new @@ -784,20 +788,20 @@ public async Task ImportUTXOs(TrackedSourceContext trackedSourceC return Ok(); } - internal async Task GetAnnotatedTransactions(Repository repo, TrackedSource trackedSource, bool includeTransaction, uint256 txId = null) + internal async Task GetAnnotatedTransactions(Repository repo, GetTransactionQuery.TrackedSourceTxId query, bool includeTransaction) { - var transactions = await repo.GetTransactions(trackedSource, txId, includeTransaction, this.HttpContext?.RequestAborted ?? default); + var transactions = await repo.GetTransactions(query, includeTransaction, this.HttpContext?.RequestAborted ?? default); // If the called is interested by only a single txId, we need to fetch the parents as well - if (txId != null) + if (query.TxId != null) { var spentOutpoints = transactions.SelectMany(t => t.SpentOutpoints.Select(o => o.Outpoint.Hash)).ToHashSet(); - var gettingParents = spentOutpoints.Select(async h => await repo.GetTransactions(trackedSource, h)).ToList(); + var gettingParents = spentOutpoints.Select(async h => await repo.GetTransactions(GetTransactionQuery.Create(query.TrackedSource, h))).ToList(); await Task.WhenAll(gettingParents); transactions = gettingParents.SelectMany(p => p.GetAwaiter().GetResult()).Concat(transactions).ToArray(); } - return new AnnotatedTransactionCollection(transactions, trackedSource, repo.Network.NBitcoinNetwork); + return new AnnotatedTransactionCollection(transactions, query.TrackedSource, repo.Network.NBitcoinNetwork); } [HttpPost] @@ -844,7 +848,7 @@ public async Task Broadcast( if (trackedSourceContext.TrackedSource != null && ex.Message.StartsWith("Missing inputs", StringComparison.OrdinalIgnoreCase)) { Logs.Explorer.LogInformation($"{network.CryptoCode}: Trying to broadcast unconfirmed of the wallet"); - var transactions = await GetAnnotatedTransactions(repo, trackedSourceContext.TrackedSource, true); + var transactions = await GetAnnotatedTransactions(repo, GetTransactionQuery.Create(trackedSourceContext.TrackedSource), true); foreach (var existing in transactions.UnconfirmedTransactions) { var t = existing.Record.Transaction ?? (await repo.GetSavedTransactions(existing.Record.TransactionHash)).Select(c => c.Transaction).FirstOrDefault(); diff --git a/NBXplorer/ModelBinders/DateTimeOffsetModelBinder.cs b/NBXplorer/ModelBinders/DateTimeOffsetModelBinder.cs new file mode 100644 index 000000000..80c67b2aa --- /dev/null +++ b/NBXplorer/ModelBinders/DateTimeOffsetModelBinder.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.VisualBasic.CompilerServices; + +namespace NBXplorer.ModelBinders +{ + public class DateTimeOffsetModelBinder : IModelBinder + { + public Task BindModelAsync(ModelBindingContext bindingContext) + { + if (!typeof(DateTimeOffset).GetTypeInfo().IsAssignableFrom(bindingContext.ModelType) && + !typeof(DateTimeOffset?).GetTypeInfo().IsAssignableFrom(bindingContext.ModelType)) + { + return Task.CompletedTask; + } + ValueProviderResult val = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + string v = val.FirstValue as string; + if (string.IsNullOrEmpty(v)) + { + return Task.CompletedTask; + } + + try + { + var sec = long.Parse(v, CultureInfo.InvariantCulture); + bindingContext.Result = ModelBindingResult.Success(NBitcoin.Utils.UnixTimeToDateTime(sec)); + } + catch + { + bindingContext.Result = ModelBindingResult.Failed(); + bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Invalid unix timestamp"); + } + return Task.CompletedTask; + } + } +} diff --git a/NBXplorer/wwwroot/api.json b/NBXplorer/wwwroot/api.json index 1a7f50696..d18b523b8 100644 --- a/NBXplorer/wwwroot/api.json +++ b/NBXplorer/wwwroot/api.json @@ -1797,6 +1797,26 @@ }, "description": "Optional. If set to `true`, each transaction in the response will include the raw transaction data in hexadecimal format (`transaction` field). Default is `false`." }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "example": 1732769702, + "description": "Indicates the earliest date from which transactions should be retrieved, (default: null, unix time)" + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "example": 1732769717, + "description": "Indicates the latest date up to which transactions should be retrieved, (default: null, unix time" + }, { "name": "accountedOnly", "in": "query",