Skip to content

Commit 916a52b

Browse files
Add fee and mempool information to txs fetched in the mempool (#508)
1 parent 2ed4f78 commit 916a52b

15 files changed

+297
-162
lines changed

NBXplorer.Client/Models/GetTransactionsResponse.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,6 @@ public IMoney BalanceChange
106106
public uint256 ReplacedBy { get; set; }
107107
public uint256 Replacing { get; set; }
108108
public bool Replaceable { get; set; }
109+
public TransactionMetadata Metadata { get; set; }
109110
}
110111
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using NBitcoin;
2+
using NBitcoin.JsonConverters;
3+
using Newtonsoft.Json;
4+
using Newtonsoft.Json.Linq;
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Text;
8+
9+
namespace NBXplorer.Models
10+
{
11+
public class TransactionMetadata
12+
{
13+
[JsonProperty("vsize", DefaultValueHandling = DefaultValueHandling.Ignore)]
14+
public int? VirtualSize { get; set; }
15+
[JsonProperty("fees", DefaultValueHandling = DefaultValueHandling.Ignore)]
16+
[JsonConverter(typeof(NBXplorer.JsonConverters.MoneyJsonConverter))]
17+
public Money Fees { get; set; }
18+
[JsonProperty("feeRate", DefaultValueHandling = DefaultValueHandling.Ignore)]
19+
[JsonConverter(typeof(NBitcoin.JsonConverters.FeeRateJsonConverter))]
20+
public FeeRate FeeRate { get; set; }
21+
public static TransactionMetadata Parse(string json) => JsonConvert.DeserializeObject<TransactionMetadata>(json);
22+
public string ToString(bool indented) => JsonConvert.SerializeObject(this, indented ? Formatting.Indented : Formatting.None);
23+
public override string ToString() => ToString(true);
24+
25+
[JsonExtensionData]
26+
public IDictionary<string, JToken> AdditionalData { get; set; } = new Dictionary<string, JToken>();
27+
}
28+
}

NBXplorer.Client/Models/TransactionResult.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,7 @@ public DateTimeOffset Timestamp
5555
set;
5656
}
5757
public uint256 ReplacedBy { get; set; }
58+
59+
public TransactionMetadata Metadata { get; set; }
5860
}
5961
}

NBXplorer.Tests/ServerTester.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,6 @@ public static string GetTestPostgres(string dbName, string applicationName)
206206
public HttpClient HttpClient { get; internal set; }
207207

208208
string datadir;
209-
210209
public void ResetExplorer(bool deleteAll = true)
211210
{
212211
Host.Dispose();

NBXplorer.Tests/UnitTest1.cs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
using System.Globalization;
2828
using System.Net;
2929
using NBXplorer.HostedServices;
30-
using NBitcoin.Altcoins;
3130
using static NBXplorer.Backend.DbConnectionHelper;
3231

3332
namespace NBXplorer.Tests
@@ -1179,8 +1178,12 @@ public async Task ShowRBFedTransaction4()
11791178
[InlineData(false)]
11801179
public async Task ShowRBFedTransaction3(bool cancelB)
11811180
{
1182-
// Let's do a chain of two transactions implicating Bob A and B.
1183-
// Then B get replaced by B'.
1181+
// Let's do a chain of two transactions
1182+
// A: Cashcow sends money to Bob (100K sats)
1183+
// B: Cashcow spends the change to another address of Bob (200K sats)
1184+
// Cashcow then create B' which will double spend B.
1185+
// If `cancelB==true`: B' cancel the 200K output of B and send it back to himself
1186+
// Else, B' just bump the fees.
11841187
// We should make sure that B' is still saved in the database, and B properly marked as replaced.
11851188
// If cancelB is true, then B' output shouldn't be related to Bob.
11861189
using var tester = ServerTester.Create();
@@ -1191,6 +1194,7 @@ public async Task ShowRBFedTransaction3(bool cancelB)
11911194
var bobAddr = await tester.Client.GetUnusedAsync(bob, DerivationFeature.Deposit, 0);
11921195
var bobAddr1 = await tester.Client.GetUnusedAsync(bob, DerivationFeature.Deposit, 1);
11931196

1197+
// A: Cashcow sends money to Bob (100K sats)
11941198
var aId = tester.RPC.SendToAddress(bobAddr.ScriptPubKey, Money.Satoshis(100_000), new SendToAddressParameters() { Replaceable = true });
11951199
var a = tester.Notifications.WaitForTransaction(bob, aId).TransactionData.Transaction;
11961200
Logs.Tester.LogInformation("a: " + aId);
@@ -1199,13 +1203,16 @@ public async Task ShowRBFedTransaction3(bool cancelB)
11991203
var changeAddr = a.Outputs.Where(o => o.ScriptPubKey != bobAddr.ScriptPubKey).First().ScriptPubKey;
12001204
LockTestCoins(tester.RPC, new HashSet<Script>() { changeAddr });
12011205

1206+
// B: Cashcow spends the change to another address of Bob (200K sats)
12021207
var bId = tester.RPC.SendToAddress(bobAddr1.ScriptPubKey, Money.Satoshis(200_000), new SendToAddressParameters() { Replaceable = true });
12031208
var b = tester.Notifications.WaitForTransaction(bob, bId).TransactionData.Transaction;
12041209
Logs.Tester.LogInformation("b: " + bId);
12051210

12061211
// b' shouldn't have any output belonging to our wallets.
12071212
var bp = b.Clone();
12081213
var o = bp.Outputs.First(o => o.ScriptPubKey == bobAddr1.ScriptPubKey);
1214+
1215+
// If `cancelB==true`: B' cancel the 200K output of B and send it back to himself
12091216
if (cancelB)
12101217
o.ScriptPubKey = changeAddr;
12111218
o.Value -= Money.Satoshis(5000); // Add some fee to bump the tx
@@ -1218,17 +1225,28 @@ public async Task ShowRBFedTransaction3(bool cancelB)
12181225
await tester.RPC.SendRawTransactionAsync(bp);
12191226
Logs.Tester.LogInformation("bp: " + bp.GetHash());
12201227

1221-
// If not a cancellation, B' should send an event, and replacing B
1228+
// If not a cancellation, B' should send an event to bob wallet, and replacing B
12221229
if (!cancelB)
12231230
{
12241231
var evt = tester.Notifications.WaitForTransaction(bob, bp.GetHash());
12251232
Assert.Equal(bId, Assert.Single(evt.Replacing));
1233+
Assert.NotNull(evt.TransactionData.Metadata.VirtualSize);
1234+
Assert.NotNull(evt.TransactionData.Metadata.FeeRate);
1235+
Assert.NotNull(evt.TransactionData.Metadata.Fees);
12261236
}
12271237

12281238
tester.Notifications.WaitForBlocks(tester.RPC.EnsureGenerate(1));
1239+
12291240
var bpr = await tester.Client.GetTransactionAsync(bp.GetHash());
12301241
Assert.NotNull(bpr?.Transaction);
12311242
Assert.Equal(1, bpr.Confirmations);
1243+
1244+
// We are sure that bpr passed by the mempool before being mined
1245+
if (!cancelB)
1246+
{
1247+
Assert.NotNull(bpr.Metadata);
1248+
}
1249+
12321250
var br = await tester.Client.GetTransactionAsync(b.GetHash());
12331251
Assert.NotNull(br?.Transaction);
12341252
Assert.Equal(bp.GetHash(), br.ReplacedBy);
@@ -2857,6 +2875,11 @@ public async Task CanGetTransactionsOfDerivation()
28572875
Assert.Equal(Money.Coins(-0.8m), result.UnconfirmedTransactions.Transactions[0].BalanceChange);
28582876
var tx3 = await tester.Client.GetTransactionAsync(pubkey, txId3);
28592877
Assert.Equal(Money.Coins(-0.8m), tx3.BalanceChange);
2878+
2879+
var metadata = result.UnconfirmedTransactions.Transactions[0].Metadata;
2880+
Assert.NotNull(metadata.Fees);
2881+
Assert.NotNull(metadata.FeeRate);
2882+
Assert.NotNull(metadata.VirtualSize);
28602883
}
28612884
}
28622885

@@ -3082,6 +3105,9 @@ public async Task CanTrack()
30823105

30833106
Logs.Tester.LogInformation("Let's check that we can query the UTXO with 2 confirmations");
30843107
tx = tester.Client.GetTransaction(tx.Transaction.GetHash());
3108+
Assert.Equal(tx.Transaction.GetVirtualSize(), tx.Metadata.VirtualSize);
3109+
Assert.NotNull(tx.Metadata.Fees);
3110+
Assert.NotNull(tx.Metadata.FeeRate);
30853111
Assert.Equal(2, tx.Confirmations);
30863112
Assert.NotNull(tx.BlockId);
30873113

NBXplorer/Backend/DbConnectionHelper.cs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#nullable enable
22
using Dapper;
33
using NBitcoin;
4+
using NBitcoin.RPC;
45
using NBXplorer.DerivationStrategy;
56
using Npgsql;
67
using System;
@@ -115,7 +116,7 @@ public record SaveTransactionRecord(Transaction? Transaction, uint256 Id, uint25
115116
);
116117
}
117118

118-
public async Task SaveTransactions(IEnumerable<SaveTransactionRecord> transactions)
119+
public async Task SaveTransactions(IEnumerable<SaveTransactionRecord> transactions, Dictionary<uint256, MempoolEntry> mempoolEntries)
119120
{
120121
var parameters = transactions
121122
.DistinctBy(o => o.Id)
@@ -124,19 +125,26 @@ public async Task SaveTransactions(IEnumerable<SaveTransactionRecord> transactio
124125
{
125126
code = Network.CryptoCode,
126127
blk_id = tx.BlockId?.ToString(),
127-
id = tx.Id?.ToString() ?? tx.Transaction?.GetHash()?.ToString(),
128+
id = tx.Id.ToString(),
128129
raw = tx.Transaction?.ToBytes(),
129130
mempool = tx.BlockId is null,
130131
seen_at = tx.SeenAt,
131132
blk_idx = tx.BlockIndex is int i ? i : 0,
132133
blk_height = tx.BlockHeight,
133-
immature = tx.Immature
134+
immature = tx.Immature,
135+
metadata = mempoolEntries.TryGetValue(tx.Id, out var meta) ? meta.ToTransactionMetadata().ToString(false) : null
134136
})
135137
.Where(o => o.id is not null)
136138
.ToArray();
137-
await Connection.ExecuteAsync("INSERT INTO txs(code, tx_id, raw, immature, seen_at) VALUES (@code, @id, @raw, @immature, COALESCE(@seen_at, CURRENT_TIMESTAMP)) " +
138-
" ON CONFLICT (code, tx_id) " +
139-
" DO UPDATE SET seen_at=LEAST(COALESCE(@seen_at, CURRENT_TIMESTAMP), txs.seen_at), raw = COALESCE(@raw, txs.raw), immature=EXCLUDED.immature", parameters);
139+
await Connection.ExecuteAsync("""
140+
INSERT INTO txs(code, tx_id, raw, immature, seen_at, metadata) VALUES (@code, @id, @raw, @immature, COALESCE(@seen_at, CURRENT_TIMESTAMP), @metadata::JSONB)
141+
ON CONFLICT (code, tx_id)
142+
DO UPDATE SET
143+
seen_at=LEAST(COALESCE(@seen_at, CURRENT_TIMESTAMP), txs.seen_at),
144+
raw = COALESCE(@raw, txs.raw),
145+
immature=EXCLUDED.immature,
146+
metadata=COALESCE(@metadata::JSONB, txs.metadata);
147+
""", parameters);
140148
await Connection.ExecuteAsync("INSERT INTO blks_txs VALUES (@code, @blk_id, @id, @blk_idx) ON CONFLICT DO NOTHING", parameters.Where(p => p.blk_id is not null).AsList());
141149
}
142150

NBXplorer/Backend/Indexer.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,8 @@ private async Task SaveMatches(DbConnectionHelper conn, List<Transaction> transa
503503
Confirmations = confirmations,
504504
Timestamp = now,
505505
Transaction = matches[i].Transaction,
506-
TransactionHash = matches[i].TransactionHash
506+
TransactionHash = matches[i].TransactionHash,
507+
Metadata = matches[i].Metadata
507508
},
508509
Inputs = matches[i].MatchedInputs,
509510
Outputs = matches[i].MatchedOutputs,

NBXplorer/Backend/Repository.cs

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
using System.Text.RegularExpressions;
1919
using Npgsql;
2020
using static NBXplorer.Backend.DbConnectionHelper;
21-
using Microsoft.AspNetCore.DataProtection.KeyManagement;
2221

2322

2423
namespace NBXplorer.Backend
@@ -72,7 +71,7 @@ JOIN unnest(@keypaths) AS k(keypath) ON nbxv1_get_keypath_index(d.metadata, k.ke
7271
code = Network.CryptoCode,
7372
wid = w.wid,
7473
keypaths = keyPaths.Select(k => k.ToString()).ToArray()
75-
}) ;
74+
});
7675
}
7776

7877
public record DescriptorKey(string code, string descriptor);
@@ -629,19 +628,25 @@ public async Task<TrackedTransaction[]> GetMatches(DbConnectionHelper connection
629628
}
630629
async Task<(TrackedTransaction[] TrackedTransactions, SaveTransactionRecord[] Saved)> SaveMatches(DbConnectionHelper connection, MatchQuery matchQuery, IList<SaveTransactionRecord> records, CancellationToken cancellationToken = default)
631630
{
632-
HashSet<uint256> unconfTxs = await connection.GetUnconfirmedTxs();
631+
HashSet<uint256> unconfTxs = null;;
633632
Dictionary<uint256, SaveTransactionRecord> txs = new();
634633
HashSet<uint256> savedTxs = new();
635634
List<dynamic> matchedConflicts = new List<dynamic>();
636635
var scripts = new List<Script>();
637636
foreach (var record in records)
638637
{
639638
txs.TryAdd(record.Id, record);
640-
if (record.BlockId is not null && unconfTxs.Contains(record.Id))
641-
// If a block has been found, and we have some unconf transactions
642-
// then we want to add an entry in blks_txs, even if the unconf tx isn't matching
643-
// any wallet. So we add record.
644-
savedTxs.Add(record.Id);
639+
if (record.BlockId is not null)
640+
{
641+
unconfTxs ??= await connection.GetUnconfirmedTxs();
642+
if (unconfTxs.Contains(record.Id))
643+
{
644+
// If a block has been found, and we have some unconf transactions
645+
// then we want to add an entry in blks_txs, even if the unconf tx isn't matching
646+
// any wallet. So we add record.
647+
savedTxs.Add(record.Id);
648+
}
649+
}
645650
}
646651
SaveTransactionRecord[] GetSavedTxs() => txs.Values.Where(r => savedTxs.Contains(r.Id)).ToArray();
647652

@@ -681,7 +686,11 @@ public async Task<TrackedTransaction[]> GetMatches(DbConnectionHelper connection
681686
end:
682687
if (savedTxs.Count is 0)
683688
return (Array.Empty<TrackedTransaction>(), GetSavedTxs());
684-
await connection.SaveTransactions(savedTxs.Select(h => txs[h]).ToArray());
689+
690+
var metadata = await rpc.FetchMempoolInfo(GetSavedTxs().Where(tx => tx.BlockId is null).Select(tx => tx.Id), cancellationToken);
691+
await connection.SaveTransactions(GetSavedTxs(), metadata);
692+
693+
685694
if (scripts.Count is 0)
686695
return (Array.Empty<TrackedTransaction>(), GetSavedTxs());
687696
var allKeyInfos = await GetKeyInformations(connection.Connection, scripts);
@@ -727,21 +736,22 @@ private void AddReplacementInfo(List<dynamic> matchedConflicts, TrackedTransacti
727736
public Task CommitMatches(DbConnection connection)
728737
=> connection.ExecuteAsync("CALL save_matches(@code)", new { code = Network.CryptoCode });
729738

730-
record SavedTransactionRow(byte[] raw, string blk_id, long? blk_height, string replaced_by, DateTime seen_at);
731-
public async Task<SavedTransaction[]> GetSavedTransactions(uint256 txid)
739+
record SavedTransactionRow(byte[] raw, string metadata, string blk_id, long? blk_height, string replaced_by, DateTime seen_at);
740+
public async Task<SavedTransaction> GetSavedTransaction(uint256 txid)
732741
{
733742
await using var connection = await connectionFactory.CreateConnectionHelper(Network);
734-
var tx = await connection.Connection.QueryFirstOrDefaultAsync<SavedTransactionRow>("SELECT raw, blk_id, blk_height, replaced_by, seen_at FROM txs WHERE code=@code AND tx_id=@tx_id", new { code = Network.CryptoCode, tx_id = txid.ToString() });
743+
var tx = await connection.Connection.QueryFirstOrDefaultAsync<SavedTransactionRow>("SELECT raw, metadata, blk_id, blk_height, replaced_by, seen_at FROM txs WHERE code=@code AND tx_id=@tx_id", new { code = Network.CryptoCode, tx_id = txid.ToString() });
735744
if (tx?.raw is null)
736-
return Array.Empty<SavedTransaction>();
737-
return new[] { new SavedTransaction()
745+
return null;
746+
return new SavedTransaction()
738747
{
739748
BlockHash = tx.blk_id is null ? null : uint256.Parse(tx.blk_id),
740749
BlockHeight = tx.blk_height,
741750
Timestamp = new DateTimeOffset(tx.seen_at),
742751
Transaction = Transaction.Load(tx.raw, Network.NBitcoinNetwork),
743-
ReplacedBy = tx.replaced_by is null ? null : uint256.Parse(tx.replaced_by)
744-
}};
752+
ReplacedBy = tx.replaced_by is null ? null : uint256.Parse(tx.replaced_by),
753+
Metadata = tx.metadata is null ? null : TransactionMetadata.Parse(tx.metadata)
754+
};
745755
}
746756
public async Task<TrackedTransaction[]> GetTransactions(GetTransactionQuery query, bool includeTransactions = true, CancellationToken cancellation = default)
747757
{
@@ -794,26 +804,32 @@ async Task<TrackedTransaction[]> GetTransactions(DbConnectionHelper connection,
794804
}
795805
}
796806

797-
var txsToFetch = (includeTransactions ? trackedById.Keys.Select(t => t.Item2).AsList() :
798-
// For double spend detection, we need the full transactions from unconfs
799-
trackedById.Where(t => t.Value.BlockHash is null).Select(t => t.Key.Item2).AsList()).ToHashSet();
800-
var txRaws = txsToFetch.Count > 0
801-
? await connection.Connection.QueryAsync<(string tx_id, byte[] raw)>(
802-
"SELECT t.tx_id, t.raw FROM unnest(@txId) i " +
803-
"JOIN txs t ON t.code=@code AND t.tx_id=i " +
804-
"WHERE t.raw IS NOT NULL;", new { code = Network.CryptoCode, txId = txsToFetch.ToArray() })
805-
: Array.Empty<(string tx_id, byte[] raw)>();
806-
807-
var txRawsById = txRaws.ToDictionary(t => t.tx_id);
807+
var txsToFetch = trackedById.Keys.Select(t => t.Item2).ToHashSet();
808+
809+
var txMetadata = await connection.Connection.QueryAsync<(string tx_id, byte[] raw, string metadata)>(
810+
$"SELECT t.tx_id, t.raw, t.metadata FROM unnest(@txId) i " +
811+
"JOIN txs t ON t.code=@code AND t.tx_id=i;", new { code = Network.CryptoCode, txId = txsToFetch.ToArray() });
812+
813+
var txMetadataByIds = txMetadata.ToDictionary(t => t.tx_id);
808814
foreach (var tracked in trackedById.Values)
809815
{
810816
tracked.Sort();
811-
if (!txRawsById.TryGetValue(tracked.Key.TxId.ToString(), out var row))
817+
if (!txMetadataByIds.TryGetValue(tracked.Key.TxId.ToString(), out var row))
812818
continue;
813-
tracked.Transaction = Transaction.Load(row.raw, Network.NBitcoinNetwork);
814-
tracked.Key = tracked.Key with { IsPruned = false };
815-
if (tracked.BlockHash is null) // Only need the spend outpoint for double spend detection on unconf txs
816-
tracked.SpentOutpoints.AddInputs(tracked.Transaction);
819+
Transaction tx = (includeTransactions || tracked.BlockHash is null) && row.raw is not null ? Transaction.Load(row.raw, Network.NBitcoinNetwork) : null;
820+
if (tx is not null)
821+
{
822+
tracked.Transaction = tx;
823+
tracked.Key = tracked.Key with { IsPruned = false };
824+
}
825+
if (row.metadata is not null)
826+
{
827+
tracked.Metadata = TransactionMetadata.Parse(row.metadata);
828+
}
829+
if (tracked.BlockHash is null && tx is not null) // Only need the spend outpoint for double spend detection on unconf txs
830+
{
831+
tracked.SpentOutpoints.AddInputs(tx);
832+
}
817833
}
818834

819835
if (Network.IsElement)
@@ -824,7 +840,7 @@ async Task<TrackedTransaction[]> GetTransactions(DbConnectionHelper connection,
824840

825841
private async Task UnblindTrackedTransactions(DbConnectionHelper connection, IEnumerable<TrackedTransaction> trackedTransactions, GetTransactionQuery query)
826842
{
827-
var keyInfos = ((query as GetTransactionQuery.ScriptsTxIds)?.KeyInfos)?.ToMultiValueDictionary(k => k.ScriptPubKey);
843+
var keyInfos = ((query as GetTransactionQuery.ScriptsTxIds)?.KeyInfos)?.ToMultiValueDictionary(k => k.ScriptPubKey);
828844
if (keyInfos is null)
829845
keyInfos = (await this.GetKeyInformations(connection.Connection, trackedTransactions.SelectMany(t => t.InOuts).Select(s => s.ScriptPubKey).ToList()));
830846
foreach (var tracked in trackedTransactions)
@@ -1065,12 +1081,6 @@ public async Task<TMetadata> GetMetadata<TMetadata>(TrackedSource source, string
10651081
return await helper.GetMetadata<TMetadata>(walletKey.wid, key);
10661082
}
10671083

1068-
public async Task SaveTransactions(SaveTransactionRecord[] records)
1069-
{
1070-
await using var helper = await connectionFactory.CreateConnectionHelper(Network);
1071-
await helper.SaveTransactions(records);
1072-
}
1073-
10741084
public async Task SetIndexProgress(BlockLocator locator)
10751085
{
10761086
await using var conn = await connectionFactory.CreateConnection();

NBXplorer/Backend/SavedTransaction.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using NBitcoin;
2+
using NBXplorer.Models;
23
using System;
34

45
namespace NBXplorer.Backend
@@ -23,5 +24,7 @@ public DateTimeOffset Timestamp
2324
set;
2425
}
2526
public uint256 ReplacedBy { get; set; }
27+
28+
public TransactionMetadata Metadata { get; set; }
2629
}
2730
}

NBXplorer/Controllers/MainController.PSBT.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -584,8 +584,8 @@ await Task.WhenAll(update.PSBT.Inputs
584584
// If this is not segwit, or we are unsure of it, let's try to grab from our saved transactions
585585
if (input.NonWitnessUtxo == null)
586586
{
587-
var prev = await repo.GetSavedTransactions(input.PrevOut.Hash);
588-
if (prev.FirstOrDefault() is SavedTransaction saved)
587+
var prev = await repo.GetSavedTransaction(input.PrevOut.Hash);
588+
if (prev is SavedTransaction saved)
589589
{
590590
input.NonWitnessUtxo = saved.Transaction;
591591
}

0 commit comments

Comments
 (0)