Skip to content

Commit

Permalink
GetTransactions can be called with from/to timestamp filter (#490)
Browse files Browse the repository at this point in the history
  • Loading branch information
NicolasDorier authored Nov 28, 2024
1 parent c249b84 commit f165b14
Show file tree
Hide file tree
Showing 10 changed files with 126 additions and 34 deletions.
26 changes: 18 additions & 8 deletions NBXplorer.Client/ExplorerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -366,22 +366,32 @@ public Task<StatusResult> GetStatusAsync(CancellationToken cancellation = defaul
{
return SendAsync<StatusResult>(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<GetTransactionsResponse> GetTransactionsAsync(DerivationStrategyBase strategy, CancellationToken cancellation = default)
public Task<GetTransactionsResponse> 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<GetTransactionsResponse> GetTransactionsAsync(TrackedSource trackedSource, CancellationToken cancellation = default)
public Task<GetTransactionsResponse> GetTransactionsAsync(TrackedSource trackedSource, DateTimeOffset? from = null, DateTimeOffset? to = null, CancellationToken cancellation = default)
{
return SendAsync<GetTransactionsResponse>(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<GetTransactionsResponse>(HttpMethod.Get, null, $"{GetBasePath(trackedSource)}/transactions?from={fromV}&to={toV}", cancellation);
}


Expand Down
2 changes: 1 addition & 1 deletion NBXplorer.Client/NBXplorer.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFrameworks>netstandard2.1</TargetFrameworks>
<Company>Digital Garage</Company>
<Version>4.3.4</Version>
<Version>4.3.5</Version>
<Copyright>Copyright © Digital Garage 2017</Copyright>
<Description>Client API for the minimalist HD Wallet Tracker NBXplorer</Description>
<PackageIcon>Bitcoin.png</PackageIcon>
Expand Down
14 changes: 12 additions & 2 deletions NBXplorer.Tests/UnitTest1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2211,15 +2211,15 @@ 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<RepositoryProvider>().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);
var records = new SaveTransactionRecord[] { SaveTransactionRecord.Create(tx: tx.Transaction, seenAt: DateTimeOffset.UtcNow + TimeSpan.FromSeconds(2)) };
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);
}
Expand Down Expand Up @@ -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);

Expand Down
25 changes: 18 additions & 7 deletions NBXplorer/Backend/GetTransactionQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 0 additions & 4 deletions NBXplorer/Backend/Repository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -677,10 +677,6 @@ public async Task<SavedTransaction[]> GetSavedTransactions(uint256 txid)
ReplacedBy = tx.replaced_by is null ? null : uint256.Parse(tx.replaced_by)
}};
}


public Task<TrackedTransaction[]> GetTransactions(TrackedSource trackedSource, uint256 txId = null, bool includeTransactions = true, CancellationToken cancellation = default) =>
GetTransactions(GetTransactionQuery.Create(trackedSource, txId), includeTransactions, cancellation);
public async Task<TrackedTransaction[]> GetTransactions(GetTransactionQuery query, bool includeTransactions = true, CancellationToken cancellation = default)
{
await using var connection = await connectionFactory.CreateConnectionHelper(Network);
Expand Down
4 changes: 2 additions & 2 deletions NBXplorer/Controllers/DerivationSchemesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ public async Task<IActionResult> 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();
}
Expand Down Expand Up @@ -149,7 +149,7 @@ public async Task<PruneResponse> 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<uint256>();

Expand Down
2 changes: 1 addition & 1 deletion NBXplorer/Controllers/MainController.PSBT.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 13 additions & 9 deletions NBXplorer/Controllers/MainController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,6 @@ public async Task<IActionResult> GetStatus(string cryptoCode)
[Route($"{CommonRoutes.BaseCryptoEndpoint}/connect")]
public async Task<IActionResult> ConnectWebSocket(
string cryptoCode,
bool includeTransaction = true,
CancellationToken cancellation = default)
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
Expand Down Expand Up @@ -550,7 +549,11 @@ public async Task<IActionResult> 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;
Expand All @@ -560,7 +563,8 @@ public async Task<IActionResult> 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
Expand Down Expand Up @@ -784,20 +788,20 @@ public async Task<IActionResult> ImportUTXOs(TrackedSourceContext trackedSourceC
return Ok();
}

internal async Task<AnnotatedTransactionCollection> GetAnnotatedTransactions(Repository repo, TrackedSource trackedSource, bool includeTransaction, uint256 txId = null)
internal async Task<AnnotatedTransactionCollection> 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]
Expand Down Expand Up @@ -844,7 +848,7 @@ public async Task<BroadcastResult> 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();
Expand Down
41 changes: 41 additions & 0 deletions NBXplorer/ModelBinders/DateTimeOffsetModelBinder.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
20 changes: 20 additions & 0 deletions NBXplorer/wwwroot/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit f165b14

Please sign in to comment.