Skip to content

Commit c249b84

Browse files
authored
Import utxo (#465)
1 parent 531f288 commit c249b84

File tree

6 files changed

+249
-1
lines changed

6 files changed

+249
-1
lines changed

NBXplorer.Client/ExplorerClient.cs

+9
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,15 @@ public Task AddGroupAddressAsync(string cryptoCode, string groupId, string[] add
583583
{
584584
return SendAsync<GroupInformation>(HttpMethod.Post, addresses, $"v1/cryptos/{cryptoCode}/groups/{groupId}/addresses", cancellationToken);
585585
}
586+
587+
public async Task ImportUTXOs(string cryptoCode, ImportUTXORequest request, CancellationToken cancellation = default)
588+
{
589+
if (request == null)
590+
throw new ArgumentNullException(nameof(request));
591+
if (cryptoCode == null)
592+
throw new ArgumentNullException(nameof(cryptoCode));
593+
await SendAsync(HttpMethod.Post, request, $"v1/cryptos/{cryptoCode}/rescan-utxos", cancellation);
594+
}
586595

587596
private static readonly HttpClient SharedClient = new HttpClient();
588597
internal HttpClient Client = SharedClient;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using NBitcoin;
2+
using Newtonsoft.Json;
3+
4+
namespace NBXplorer.Models;
5+
6+
public class ImportUTXORequest
7+
{
8+
[JsonProperty("UTXOs")]
9+
public OutPoint[] Utxos { get; set; }
10+
}

NBXplorer.Tests/UnitTest1.cs

+122
Original file line numberDiff line numberDiff line change
@@ -4269,5 +4269,127 @@ public async Task IsTrackedTests()
42694269
group = new GroupTrackedSource((await tester.Client.CreateGroupAsync(Cancel)).GroupId);
42704270
Assert.True(await tester.Client.IsTrackedAsync(group, Cancel));
42714271
}
4272+
[Fact]
4273+
public async Task CanImportUTXOs()
4274+
{
4275+
using var tester = ServerTester.Create();
4276+
4277+
var wallet1 = await tester.Client.CreateGroupAsync();
4278+
var wallet1TS = new GroupTrackedSource(wallet1.GroupId);
4279+
4280+
var k = new Key();
4281+
var kAddress = k.GetAddress(ScriptPubKeyType.Segwit, tester.Network);
4282+
4283+
// We use this one because it allows us to use WaitForTransaction later
4284+
var legacy = new AddressTrackedSource(new Key().GetAddress(ScriptPubKeyType.Legacy, tester.Network));
4285+
await tester.Client.TrackAsync(legacy);
4286+
4287+
var kScript = kAddress.ScriptPubKey;
4288+
4289+
// test 1: create a script and send 2 utxos to it(from diff txs), without confirming
4290+
// import the first one, verify it is unconfirmed, confirm, then the second one and see it is confirmed
4291+
4292+
var tx = await tester.RPC.SendToAddressAsync(kAddress, Money.Coins(1.0m));
4293+
var tx2 = await tester.RPC.SendToAddressAsync(kAddress, Money.Coins(1.0m));
4294+
var rawTx = await tester.RPC.GetRawTransactionAsync(tx);
4295+
var rawTx2 = await tester.RPC.GetRawTransactionAsync(tx2);
4296+
var utxo = rawTx.Outputs.AsIndexedOutputs().First(o => o.TxOut.ScriptPubKey == kScript);
4297+
var utxo2 = rawTx2.Outputs.AsIndexedOutputs().First(o => o.TxOut.ScriptPubKey == kScript);
4298+
4299+
// Making sure that tx and tx2 are processed before continuing
4300+
var tx3 = await tester.RPC.SendToAddressAsync(legacy.Address, Money.Coins(1.0m));
4301+
var notif = tester.Notifications.WaitForTransaction(legacy.Address, tx3);
4302+
Assert.Equal(legacy, notif.TrackedSource);
4303+
4304+
await tester.Client.AddGroupAddressAsync("BTC", wallet1.GroupId, new[] { kAddress.ToString() });
4305+
await tester.Client.ImportUTXOs("BTC", new ImportUTXORequest()
4306+
{
4307+
Utxos = [utxo.ToCoin().Outpoint]
4308+
});
4309+
4310+
var utxos = await tester.Client.GetUTXOsAsync(wallet1TS);
4311+
var matched = Assert.Single(utxos.Unconfirmed.UTXOs);
4312+
Assert.Equal(kAddress, matched.Address);
4313+
4314+
// tx2 didn't matched when it was in the mempool, so the block will not match it either because of the cache.
4315+
tester.GetService<RepositoryProvider>().GetRepository("BTC").RemoveFromCache(new[] { tx2 });
4316+
tester.Notifications.WaitForBlocks(await tester.RPC.GenerateAsync(1));
4317+
utxos = await tester.Client.GetUTXOsAsync(wallet1TS);
4318+
Assert.Equal(2, utxos.Confirmed.UTXOs.Count);
4319+
Assert.Contains(tx, utxos.Confirmed.UTXOs.Select(u => u.Outpoint.Hash));
4320+
Assert.Contains(tx2, utxos.Confirmed.UTXOs.Select(u => u.Outpoint.Hash));
4321+
4322+
await tester.Client.ImportUTXOs("BTC", new ImportUTXORequest()
4323+
{
4324+
Utxos = [utxo2.ToCoin().Outpoint]
4325+
});
4326+
4327+
utxos = await tester.Client.GetUTXOsAsync(wallet1TS);
4328+
Assert.Equal(2, utxos.Confirmed.UTXOs.Count);
4329+
//utxo2 may be confirmed but we should have saved the timestamp based on block time or current date
4330+
var utxoInfo = utxos.Confirmed.UTXOs.First(u => u.ScriptPubKey == utxo2.TxOut.ScriptPubKey);
4331+
Assert.NotEqual(NBitcoin.Utils.UnixTimeToDateTime(0), utxoInfo.Timestamp);
4332+
4333+
//test2: try adding in fake utxos or spent ones
4334+
var fakescript = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, tester.Network).ScriptPubKey;
4335+
var fakeUtxo = new Coin(new OutPoint(uint256.One, 1), new TxOut(Money.Coins(1.0m), fakescript));
4336+
var kToSpend = new Key();
4337+
var kToSpendAddress = kToSpend.GetAddress(ScriptPubKeyType.Segwit, tester.Network);
4338+
var tospendtx = await tester.RPC.SendToAddressAsync(kToSpendAddress, Money.Coins(1.0m));
4339+
var tospendrawtx = await tester.RPC.GetRawTransactionAsync(tospendtx);
4340+
var tospendutxo = tospendrawtx.Outputs.AsIndexedOutputs().First(o => o.TxOut.ScriptPubKey == kToSpendAddress.ScriptPubKey);
4341+
var validScript = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, tester.Network).ScriptPubKey;
4342+
var spendingtx = tester.Network.CreateTransactionBuilder()
4343+
.AddKeys(kToSpend)
4344+
.AddCoins(new Coin(tospendutxo))
4345+
.SendEstimatedFees(new FeeRate(100m))
4346+
.SendAll(validScript).BuildTransaction(true);
4347+
await tester.RPC.SendRawTransactionAsync(spendingtx);
4348+
4349+
var validScriptUtxo = spendingtx.Outputs.AsIndexedOutputs().First(o => o.TxOut.ScriptPubKey == validScript);
4350+
4351+
await tester.Client.ImportUTXOs("BTC", new ImportUTXORequest()
4352+
{
4353+
Utxos =
4354+
[
4355+
fakeUtxo.Outpoint,
4356+
tospendutxo.ToCoin().Outpoint,
4357+
validScriptUtxo.ToCoin().Outpoint
4358+
]
4359+
});
4360+
4361+
utxos = await tester.Client.GetUTXOsAsync(wallet1TS);
4362+
Assert.Empty(utxos.Unconfirmed.UTXOs);
4363+
4364+
// let's test add an utxo after it has been mined
4365+
var yoScript = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, tester.Network);
4366+
var yoTxId = await tester.SendToAddressAsync(yoScript, Money.Coins(1.0m));
4367+
var yoTx = await tester.RPC.GetRawTransactionAsync(yoTxId);
4368+
var yoUtxo = yoTx.Outputs.AsIndexedOutputs().First(o => o.TxOut.ScriptPubKey == yoScript.ScriptPubKey);
4369+
4370+
await tester.Client.ImportUTXOs("BTC", new ImportUTXORequest()
4371+
{
4372+
Utxos = [yoUtxo.ToCoin().Outpoint]
4373+
});
4374+
4375+
utxos = await tester.Client.GetUTXOsAsync(wallet1TS);
4376+
Assert.Empty(utxos.Unconfirmed.UTXOs);
4377+
4378+
var aaa = await tester.RPC.GenerateAsync(1);
4379+
tester.Notifications.WaitForBlocks(aaa);
4380+
utxos = await tester.Client.GetUTXOsAsync(wallet1TS);
4381+
Assert.Empty(utxos.Unconfirmed.UTXOs);
4382+
4383+
await tester.Client.AddGroupAddressAsync("BTC", wallet1.GroupId, new[] { yoScript.ToString() });
4384+
await tester.Client.ImportUTXOs("BTC", new ImportUTXORequest()
4385+
{
4386+
Utxos = [yoUtxo.ToCoin().Outpoint]
4387+
});
4388+
4389+
utxos = await tester.Client.GetUTXOsAsync(wallet1TS);
4390+
var confirmedUtxo = utxos.Confirmed.UTXOs.Single(utxo1 => utxo1.ScriptPubKey == yoScript.ScriptPubKey);
4391+
Assert.Equal(1, confirmedUtxo.Confirmations);
4392+
Assert.NotEqual(NBitcoin.Utils.UnixTimeToDateTime(0), confirmedUtxo.Timestamp);
4393+
}
42724394
}
42734395
}

NBXplorer/Controllers/MainController.cs

+51
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,57 @@ public async Task<IActionResult> Rescan(TrackedSourceContext trackedSourceContex
733733
}
734734
}
735735

736+
[HttpPost]
737+
[Route($"{CommonRoutes.BaseCryptoEndpoint}/rescan-utxos")]
738+
[TrackedSourceContext.TrackedSourceContextRequirement(false, false, true)]
739+
public async Task<IActionResult> ImportUTXOs(TrackedSourceContext trackedSourceContext, [FromBody] ImportUTXORequest request, CancellationToken cancellationToken = default)
740+
{
741+
var repo = trackedSourceContext.Repository;
742+
if (request.Utxos?.Any() is not true)
743+
return Ok();
744+
745+
var rpc = trackedSourceContext.RpcClient;
746+
747+
var coinToTxOut = await rpc.GetTxOuts(request.Utxos);
748+
var bestBlocksToFetch = coinToTxOut.Select(c => c.Value.BestBlock).ToHashSet().ToList();
749+
var bestBlocks = await rpc.GetBlockHeadersAsync(bestBlocksToFetch, cancellationToken);
750+
var coinsWithHeights = coinToTxOut
751+
.Select(c => new
752+
{
753+
BestBlock = bestBlocks.ByHashes.TryGet(c.Value.BestBlock),
754+
Outpoint = c.Key,
755+
RPCTxOut = c.Value
756+
})
757+
.Select(c => new
758+
{
759+
Height = c.RPCTxOut.Confirmations == 0 ? null : new int?(c.BestBlock.Height - c.RPCTxOut.Confirmations + 1),
760+
c.Outpoint,
761+
c.RPCTxOut
762+
})
763+
.ToList();
764+
var blocks = coinsWithHeights.Where(c => c.Height.HasValue).Select(c => c.Height.Value).Distinct().ToList();
765+
var blockHeaders = await rpc.GetBlockHeadersAsync(blocks, cancellationToken);
766+
767+
var now = DateTimeOffset.UtcNow;
768+
var records = new List<SaveTransactionRecord>();
769+
MatchQuery query = new MatchQuery(coinsWithHeights.Select(c => new Coin(c.Outpoint, c.RPCTxOut.TxOut)));
770+
foreach (var c in coinsWithHeights)
771+
{
772+
var block = c.Height is int h ? blockHeaders.ByHeight.TryGet(h) : null;
773+
var record = SaveTransactionRecord.Create(
774+
txHash: c.Outpoint.Hash,
775+
slimBlock: block?.ToSlimChainedBlock(),
776+
seenAt: Extensions.MinDate(block?.Time ?? now, now));
777+
records.Add(record);
778+
}
779+
await repo.SaveBlocks(blockHeaders);
780+
repo.RemoveFromCache(records.Select(r => r.Id));
781+
var trackedTransactions = await repo.SaveMatches(query, records.ToArray());
782+
_ = AddressPoolService.GenerateAddresses(trackedSourceContext.Network, trackedTransactions);
783+
784+
return Ok();
785+
}
786+
736787
internal async Task<AnnotatedTransactionCollection> GetAnnotatedTransactions(Repository repo, TrackedSource trackedSource, bool includeTransaction, uint256 txId = null)
737788
{
738789
var transactions = await repo.GetTransactions(trackedSource, txId, includeTransaction, this.HttpContext?.RequestAborted ?? default);

NBXplorer/RPCClientExtensions.cs

+17
Original file line numberDiff line numberDiff line change
@@ -379,5 +379,22 @@ public async static Task ImportDescriptors(this RPCClient rpc, string descriptor
379379
}
380380
throw new NotSupportedException($"Bug of NBXplorer (ERR 3083), please notify the developers");
381381
}
382+
public static async Task<Dictionary<OutPoint, GetTxOutResponse>> GetTxOuts(this RPCClient rpc, IList<OutPoint> outpoints)
383+
{
384+
var batch = rpc.PrepareBatch();
385+
var txOuts = outpoints.Select(o => batch.GetTxOutAsync(o.Hash, (int)o.N, true)).ToArray();
386+
await batch.SendBatchAsync();
387+
var result = new Dictionary<OutPoint, GetTxOutResponse>();
388+
int i = 0;
389+
foreach (var txOut in txOuts)
390+
{
391+
var outpoint = outpoints[i];
392+
var r = await txOut;
393+
if (r != null)
394+
result.TryAdd(outpoint, r);
395+
i++;
396+
}
397+
return result;
398+
}
382399
}
383400
}

NBXplorer/wwwroot/api.json

+40-1
Original file line numberDiff line numberDiff line change
@@ -2020,6 +2020,45 @@
20202020
}
20212021
}
20222022
},
2023+
"/v1/cryptos/{cryptoCode}/rescan-utxos": {
2024+
"post": {
2025+
"summary": "Rescan utxos",
2026+
"description": "Verifies that the UTXOs are unspent and save matches to wallets tracked by this server",
2027+
"tags": [
2028+
"Blockchain"
2029+
],
2030+
"parameters": [
2031+
{
2032+
"$ref": "#/components/parameters/CryptoCode"
2033+
}
2034+
],
2035+
"requestBody": {
2036+
"content": {
2037+
"application/json": {
2038+
"schema": {
2039+
"type": "object",
2040+
"properties": {
2041+
"UTXOs": {
2042+
"type": "array",
2043+
"description": "The outpoints to rescan",
2044+
"items": {
2045+
"type": "string",
2046+
"example": "f3e34c3ae29c79ddf807de0ab3b40da7862adb1b175b7421d89ec78446a52b13-1"
2047+
},
2048+
"example": [ "f3e34c3ae29c79ddf807de0ab3b40da7862adb1b175b7421d89ec78446a52b13-1", "fd97fb1de63fcdb9fa1614a74775e84818b2dbd79fe36aef9e0c18f9fad03742-2" ]
2049+
}
2050+
}
2051+
}
2052+
}
2053+
}
2054+
},
2055+
"responses": {
2056+
"200": {
2057+
"description": "Rescan initiated successfully."
2058+
}
2059+
}
2060+
}
2061+
},
20232062
"/v1/cryptos/{cryptoCode}/psbt/update": {
20242063
"post": {
20252064
"summary": "Update Partially Signed Bitcoin Transaction",
@@ -3728,4 +3767,4 @@
37283767
}
37293768
}
37303769
}
3731-
}
3770+
}

0 commit comments

Comments
 (0)