From 58c9fb8e21194405021360fa449284276fabd94a Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 11 Dec 2023 16:06:11 +0900 Subject: [PATCH] Add hierarchy API --- NBXplorer.Client/ExplorerClient.cs | 66 ++++ .../Models/GenerateWalletRequest.cs | 2 + NBXplorer.Client/Models/ImportUTXORequest.cs | 25 ++ NBXplorer.Client/Models/TrackWalletRequest.cs | 1 + .../Models/TrackedSourceRequest.cs | 6 + NBXplorer.Tests/UnitTest1.cs | 311 +++++++++++++++ .../Backends/Postgres/PostgresRepository.cs | 41 +- NBXplorer/Controllers/MainController.cs | 19 +- .../Controllers/PostgresMainController.cs | 139 +++++++ NBXplorer/RPCClientExtensions.cs | 25 +- docs/API.md | 355 +++++++++++++----- 11 files changed, 880 insertions(+), 110 deletions(-) create mode 100644 NBXplorer.Client/Models/ImportUTXORequest.cs create mode 100644 NBXplorer.Client/Models/TrackedSourceRequest.cs diff --git a/NBXplorer.Client/ExplorerClient.cs b/NBXplorer.Client/ExplorerClient.cs index 26b309723..ae61c5c6b 100644 --- a/NBXplorer.Client/ExplorerClient.cs +++ b/NBXplorer.Client/ExplorerClient.cs @@ -15,6 +15,7 @@ using NBitcoin.RPC; using System.Runtime.CompilerServices; using System.Linq; +using System.Diagnostics; namespace NBXplorer { @@ -394,6 +395,24 @@ public Task GetTransactionAsync(TrackedSource trackedSou return SendAsync(HttpMethod.Get, null, $"{GetBasePath(trackedSource)}/transactions/{txId}", cancellation); } + public async Task AssociateScriptsAsync(TrackedSource trackedSource, AssociateScriptRequest[] scripts, CancellationToken cancellation = default) + { + if (scripts == null) + throw new ArgumentNullException(nameof(scripts)); + if (trackedSource == null) + throw new ArgumentNullException(nameof(trackedSource)); + await SendAsync(HttpMethod.Post, scripts, $"{GetBasePath(trackedSource)}/associate", cancellation); + } + + public async Task ImportUTXOs(TrackedSource trackedSource, ImportUTXORequest request, CancellationToken cancellation = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + if (trackedSource == null) + throw new ArgumentNullException(nameof(trackedSource)); + await SendAsync(HttpMethod.Post, request, $"{GetBasePath(trackedSource)}/import-utxos", cancellation); + } + public Task RescanAsync(RescanRequest rescanRequest, CancellationToken cancellation = default) { if (rescanRequest == null) @@ -547,6 +566,53 @@ public GenerateWalletResponse GenerateWallet(GenerateWalletRequest request = nul return GenerateWalletAsync(request, cancellationToken).GetAwaiter().GetResult(); } + public async Task GetChildWallets(TrackedSource trackedSource, + CancellationToken cancellation = default) + { + return await GetAsync($"{GetBasePath(trackedSource)}/children", cancellation); + } + public async Task GetParentWallets(TrackedSource trackedSource, + CancellationToken cancellation = default) + { + return await GetAsync($"{GetBasePath(trackedSource)}/parents", cancellation); + } + public async Task AddChildWallet(TrackedSource trackedSource, TrackedSource childWallet, CancellationToken cancellation = default) + { + var request = new TrackedSourceRequest() + { + TrackedSource = childWallet + }; + await SendAsync(HttpMethod.Post, request, $"{GetBasePath(trackedSource)}/children", cancellation); + } + + public async Task AddParentWallet(TrackedSource trackedSource, TrackedSource parentWallet, + CancellationToken cancellation = default) + { + var request = new TrackedSourceRequest() + { + TrackedSource = parentWallet + }; + await SendAsync(HttpMethod.Post, request, $"{GetBasePath(trackedSource)}/parents", cancellation); + } + public async Task RemoveChildWallet(TrackedSource trackedSource, TrackedSource childWallet, CancellationToken cancellation = default) + { + var request = new TrackedSourceRequest() + { + TrackedSource = childWallet + }; + await SendAsync(HttpMethod.Delete, request, $"{GetBasePath(trackedSource)}/children", cancellation); + } + + public async Task RemoveParentWallet(TrackedSource trackedSource, TrackedSource parentWallet, + CancellationToken cancellation = default) + { + var request = new TrackedSourceRequest() + { + TrackedSource = parentWallet + }; + await SendAsync(HttpMethod.Delete, request, $"{GetBasePath(trackedSource)}/parents", cancellation); + } + private static readonly HttpClient SharedClient = new HttpClient(); internal HttpClient Client = SharedClient; diff --git a/NBXplorer.Client/Models/GenerateWalletRequest.cs b/NBXplorer.Client/Models/GenerateWalletRequest.cs index 666987fb0..baaea9658 100644 --- a/NBXplorer.Client/Models/GenerateWalletRequest.cs +++ b/NBXplorer.Client/Models/GenerateWalletRequest.cs @@ -17,5 +17,7 @@ public class GenerateWalletRequest public bool ImportKeysToRPC { get; set; } public bool SavePrivateKeys { get; set; } public Dictionary AdditionalOptions { get; set; } + + public TrackedSource ParentWallet { get; set; } } } diff --git a/NBXplorer.Client/Models/ImportUTXORequest.cs b/NBXplorer.Client/Models/ImportUTXORequest.cs new file mode 100644 index 000000000..8dc3ec877 --- /dev/null +++ b/NBXplorer.Client/Models/ImportUTXORequest.cs @@ -0,0 +1,25 @@ +using NBitcoin; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; + +namespace NBXplorer.Models; + +public class ImportUTXORequest +{ + [JsonProperty("UTXOs")] + public OutPoint[] Utxos { get; set; } +} + +public class AssociateScriptRequest +{ + public Script ScriptPubKey { get; set; } + public IDestination Destination { get; set; } + + public BitcoinAddress GetAddress(Network network) + { + return GetScriptPubKey().GetDestinationAddress(network); + } + + public Script GetScriptPubKey() => ScriptPubKey ?? Destination.ScriptPubKey; +} \ No newline at end of file diff --git a/NBXplorer.Client/Models/TrackWalletRequest.cs b/NBXplorer.Client/Models/TrackWalletRequest.cs index 19ed4f196..977d0a40c 100644 --- a/NBXplorer.Client/Models/TrackWalletRequest.cs +++ b/NBXplorer.Client/Models/TrackWalletRequest.cs @@ -7,6 +7,7 @@ public class TrackWalletRequest { public TrackDerivationOption[] DerivationOptions { get; set; } public bool Wait { get; set; } = false; + public TrackedSource ParentWallet { get; set; } } public class TrackDerivationOption diff --git a/NBXplorer.Client/Models/TrackedSourceRequest.cs b/NBXplorer.Client/Models/TrackedSourceRequest.cs new file mode 100644 index 000000000..da3837a74 --- /dev/null +++ b/NBXplorer.Client/Models/TrackedSourceRequest.cs @@ -0,0 +1,6 @@ +namespace NBXplorer.Models; + +public class TrackedSourceRequest +{ + public TrackedSource TrackedSource { get; set; } +} \ No newline at end of file diff --git a/NBXplorer.Tests/UnitTest1.cs b/NBXplorer.Tests/UnitTest1.cs index f0e861038..0548fd772 100644 --- a/NBXplorer.Tests/UnitTest1.cs +++ b/NBXplorer.Tests/UnitTest1.cs @@ -27,6 +27,8 @@ using System.Globalization; using System.Net; using NBXplorer.HostedServices; +using System.Reflection; +using NBitcoin.DataEncoders; namespace NBXplorer.Tests { @@ -4522,5 +4524,314 @@ public async Task CanUseRPCProxy(Backend backend) await tester.Client.RPCClient.GetTxOutAsync(uint256.One, 0); } } + private async Task Eventually(Func tsk) + { + var i = 0; + while (i < 10) + { + try + { + await tsk.Invoke(); + break; + } + catch (Exception) + { + await Task.Delay(500); + } + + i++; + } + } + + [Theory] + [InlineData(Backend.Postgres)] + public async Task CanAssociateIndependentScripts(Backend backend) + { + using var tester = ServerTester.Create(backend); + + var wallet1 = Guid.NewGuid().ToString(); + var wallet1TS = new WalletTrackedSource(wallet1); + var parentWallet = Guid.NewGuid().ToString(); + var parentWalletTS = new WalletTrackedSource(parentWallet); + await tester.Client.TrackAsync(wallet1TS, new TrackWalletRequest() + { + ParentWallet = parentWalletTS + }, CancellationToken.None); + + GenerateWalletResponse derivationWallet = null; + + derivationWallet = await tester.Client.GenerateWalletAsync(new GenerateWalletRequest() + { + ParentWallet = parentWalletTS + }); + + var derivationWalletTS = TrackedSource.Create(derivationWallet.DerivationScheme); + + var address = await tester.Client.GetUnusedAsync(derivationWallet.DerivationScheme, + DerivationFeature.Deposit, 0, true); + await tester.RPC.SendToAddressAsync(address.Address, Money.FromUnit(0.1m, MoneyUnit.BTC)); + var b1 = await tester.Client.GetBalanceAsync(derivationWalletTS); + var b2 = await tester.Client.GetBalanceAsync(parentWalletTS); + Assert.Equal(b1.Total, b2.Total); + + var derivationUtxos = await tester.Client.GetUTXOsAsync(derivationWalletTS); + var parentWalletUtxos = await tester.Client.GetUTXOsAsync(parentWalletTS); + Assert.Equal(derivationUtxos.GetUnspentUTXOs().Count(), parentWalletUtxos.GetUnspentUTXOs().Count()); + + var newAddr = await tester.RPC.GetNewAddressAsync(); + var newAddr2 = await tester.RPC.GetNewAddressAsync(); + var udetectedTxId = await tester.RPC.SendToAddressAsync(newAddr, Money.FromUnit(0.1m, MoneyUnit.BTC)); + await Task.Delay(3000); + var utxos = Assert.Single(await tester.RPC.ListUnspentAsync(0, 0, newAddr)); + await tester.Client.AssociateScriptsAsync(wallet1TS, new[] + { + new AssociateScriptRequest() + { + Destination = newAddr2 + } + }); + + + await tester.RPC.SendToAddressAsync(newAddr2, Money.FromUnit(0.2m, MoneyUnit.BTC)); + + derivationUtxos = await tester.Client.GetUTXOsAsync(derivationWalletTS); + UTXOChanges scriptBagUtxos = null; + //1 utxo was before we started tracking + + await Eventually(async () => + { + parentWalletUtxos = await tester.Client.GetUTXOsAsync(parentWalletTS); + scriptBagUtxos = await tester.Client.GetUTXOsAsync(wallet1TS); + Assert.Single(scriptBagUtxos.GetUnspentUTXOs()); + Assert.Equal(2, parentWalletUtxos.GetUnspentUTXOs().Length); + Assert.Equal(derivationUtxos.GetUnspentUTXOs().Count() + scriptBagUtxos.GetUnspentUTXOs().Length, + parentWalletUtxos.GetUnspentUTXOs().Count()); + }); + + + await tester.Client.AssociateScriptsAsync(wallet1TS, new[] + { + new AssociateScriptRequest() + { + Destination = newAddr + } + }); + + await tester.Client.ImportUTXOs(wallet1TS, new ImportUTXORequest() + { + Utxos = new[] + { + utxos.OutPoint, + } + }); + + await Eventually(async () => + { + scriptBagUtxos = await tester.Client.GetUTXOsAsync(wallet1TS); + Assert.Equal(2, scriptBagUtxos.GetUnspentUTXOs().Length); + }); + + //create wallet A + //create wallet b using generate and make it child of A + // create address using unused on B + // creat wallet C tracking address from B, make it child of A, B + var walletA = new WalletTrackedSource(Guid.NewGuid().ToString()); + await tester.Client.TrackAsync(walletA); + var generatResponse = await tester.Client.GenerateWalletAsync(new GenerateWalletRequest() + { + ParentWallet = walletA + }); + var walletB = TrackedSource.Create(generatResponse.DerivationScheme); + var addressA = await tester.Client.GetUnusedAsync(generatResponse.DerivationScheme, DerivationFeature.Deposit, 0, true); + var walletC = AddressTrackedSource.Create(addressA.Address); + await tester.Client.TrackAsync(walletC, new TrackWalletRequest() + { + ParentWallet = walletB + }); + await tester.Client.TrackAsync(walletC, new TrackWalletRequest() + { + ParentWallet = walletA + }); + + var kpi = await tester.Client.GetKeyInformationsAsync(addressA.ScriptPubKey, CancellationToken.None); + var tss = kpi.Select(information => information.TrackedSource); + Assert.True(tss.Distinct().Count() == tss.Count(), "The result should only distinct tracked source matches. While this endpoint is marked obsolete, the same logic is used to trigger events, which means there will be duplicated events when the script is matched against"); + + var parentsOfC = await tester.Client.GetParentWallets(walletC); + Assert.Equal(2, parentsOfC.Length); + Assert.Contains(parentsOfC, w => w == walletA); + Assert.Contains(parentsOfC, w => w == walletB); + + var parentsOfB = await tester.Client.GetParentWallets(walletB); + Assert.Single(parentsOfB); + Assert.Contains(parentsOfB, w => w == walletA); + + var parentsOfA = await tester.Client.GetParentWallets(walletA); + Assert.Empty(parentsOfA); + + var childrenOfA = await tester.Client.GetChildWallets(walletA); + Assert.Equal(2, childrenOfA.Length); + + Assert.Contains(childrenOfA, w => w == walletB); + Assert.Contains(childrenOfA, w => w == walletC); + + var childrenOfB = await tester.Client.GetChildWallets(walletB); + Assert.Single(childrenOfB); + Assert.Contains(childrenOfB, w => w == walletC); + + var childrenOfC = await tester.Client.GetChildWallets(walletC); + Assert.Empty(childrenOfC); + + await tester.Client.RemoveParentWallet(walletB, walletA); + await tester.Client.RemoveChildWallet(walletB, walletC); + + parentsOfB = await tester.Client.GetParentWallets(walletB); + Assert.Empty(parentsOfB); + + childrenOfB = await tester.Client.GetChildWallets(walletB); + Assert.Empty(childrenOfB); + + + await tester.Client.AddParentWallet(walletB, walletA); + await tester.Client.AddChildWallet(walletB, walletC); + + + childrenOfB = await tester.Client.GetChildWallets(walletB); + Assert.Single(childrenOfB); + Assert.Contains(childrenOfB, w => w == walletC); + + parentsOfB = await tester.Client.GetParentWallets(walletB); + Assert.Single(parentsOfB); + Assert.Contains(parentsOfB, w => w == walletA); + + } + + [Theory] + [InlineData(Backend.Postgres)] + public async Task CanImportUTXOs(Backend backend) + { + using var tester = ServerTester.Create(backend); + + var wallet1 = Guid.NewGuid().ToString(); + var wallet1TS = new WalletTrackedSource(wallet1); + + var k = new Key(); + var kAddress = k.GetAddress(ScriptPubKeyType.Segwit, tester.Network); + var kScript = kAddress.ScriptPubKey; + + // test 1: create a script and send 2 utxos to it(from diff txs), without confirming + // import the first one, verify it is unconfirmed, confirm, then the second one and see it is confirmed + + var tx = await tester.RPC.SendToAddressAsync(kAddress, Money.Coins(1.0m)); + var tx2 = await tester.RPC.SendToAddressAsync(kAddress, Money.Coins(1.0m)); + var rawTx = await tester.RPC.GetRawTransactionAsync(tx); + var rawTx2 = await tester.RPC.GetRawTransactionAsync(tx2); + var utxo = rawTx.Outputs.AsIndexedOutputs().First(o => o.TxOut.ScriptPubKey == kScript); + var utxo2 = rawTx2.Outputs.AsIndexedOutputs().First(o => o.TxOut.ScriptPubKey == kScript); + + await tester.Client.ImportUTXOs(wallet1TS, new ImportUTXORequest() + { + Utxos = new[] + { + utxo.ToCoin().Outpoint, + } + }); + + var utxos = await tester.Client.GetUTXOsAsync(wallet1TS); + var matched = Assert.Single(utxos.Unconfirmed.UTXOs); + Assert.Equal(kAddress, matched.Address); + + await tester.RPC.GenerateAsync(1); + await Eventually(async () => + { + var utxos = await tester.Client.GetUTXOsAsync(wallet1TS); + Assert.Single(utxos.Confirmed.UTXOs); + }); + + await tester.Client.ImportUTXOs(wallet1TS, new ImportUTXORequest() + { + Utxos = new[] + { + utxo2.ToCoin().Outpoint, + } + }); + + await Eventually(async () => + { + var utxos = await tester.Client.GetUTXOsAsync(wallet1TS); + Assert.Equal(2, utxos.Confirmed.UTXOs.Count); + + //utxo2 may be confirmed but we dont know much about it I guess? + var utxoInfo = utxos.Confirmed.UTXOs.First(u => u.ScriptPubKey == utxo2.TxOut.ScriptPubKey); + Assert.Equal(NBitcoin.Utils.UnixTimeToDateTime(0), utxoInfo.Timestamp); + }); + + //test2: try adding in fake utxos or spent ones + var fakescript = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, tester.Network).ScriptPubKey; + var fakeUtxo = new Coin(new OutPoint(uint256.One, 1), new TxOut(Money.Coins(1.0m), fakescript)); + var kToSpend = new Key(); + var kToSpendAddress = kToSpend.GetAddress(ScriptPubKeyType.Segwit, tester.Network); + var tospendtx = await tester.RPC.SendToAddressAsync(kToSpendAddress, Money.Coins(1.0m)); + var tospendrawtx = await tester.RPC.GetRawTransactionAsync(tospendtx); + var tospendutxo = tospendrawtx.Outputs.AsIndexedOutputs().First(o => o.TxOut.ScriptPubKey == kToSpendAddress.ScriptPubKey); + var validScript = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, tester.Network).ScriptPubKey; + var spendingtx = tester.Network.CreateTransactionBuilder() + .AddKeys(kToSpend) + .AddCoins(new Coin(tospendutxo)) + .SendEstimatedFees(new FeeRate(100m)) + .SendAll(validScript).BuildTransaction(true); + await tester.RPC.SendRawTransactionAsync(spendingtx); + + var validScriptUtxo = spendingtx.Outputs.AsIndexedOutputs().First(o => o.TxOut.ScriptPubKey == validScript); + + await tester.Client.ImportUTXOs(wallet1TS, new ImportUTXORequest() + { + Utxos = new[] + { + fakeUtxo.Outpoint, + new Coin(tospendutxo).Outpoint, + new Coin(validScriptUtxo).Outpoint + } + }); + + await Eventually(async () => + { + var utxos = await tester.Client.GetUTXOsAsync(wallet1TS); + Assert.Equal(validScript, Assert.Single(utxos.Unconfirmed.UTXOs).ScriptPubKey); + + }); + + + // let's test out proofs + + var yoScript = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, tester.Network).ScriptPubKey; + var yoTxId = await tester.SendToAddressAsync(yoScript, Money.Coins(1.0m)); + var yoTx = await tester.RPC.GetRawTransactionAsync(yoTxId); + var yoUtxo = yoTx.Outputs.AsIndexedOutputs().First(o => o.TxOut.ScriptPubKey == yoScript); + var blockHash = await tester.RPC.GenerateAsync(1); + var proofResult = await tester.RPC.SendCommandAsync(RPCOperations.gettxoutproof, new[] { yoTxId.ToString() }, blockHash[0].ToString()); + + + var merkleBLockProofBytes = Encoders.Hex.DecodeData(proofResult.ResultString); + var mb = new MerkleBlock(); + mb.FromBytes(merkleBLockProofBytes); + + await tester.Client.ImportUTXOs(wallet1TS, new ImportUTXORequest() + { + Utxos = new[] + { + new Coin(yoUtxo).Outpoint + } + }); + + await Eventually(async () => + { + var utxos = await tester.Client.GetUTXOsAsync(wallet1TS); + var importedUtxoWithProof = utxos.Confirmed.UTXOs.Single(utxo1 => utxo1.ScriptPubKey == yoScript); + + Assert.NotEqual(NBitcoin.Utils.UnixTimeToDateTime(0), importedUtxoWithProof.Timestamp); + + }); + } } } diff --git a/NBXplorer/Backends/Postgres/PostgresRepository.cs b/NBXplorer/Backends/Postgres/PostgresRepository.cs index 70cabec57..5cdaaafce 100644 --- a/NBXplorer/Backends/Postgres/PostgresRepository.cs +++ b/NBXplorer/Backends/Postgres/PostgresRepository.cs @@ -265,6 +265,31 @@ internal TrackedSource GetTrackedSource(WalletKey walletKey) return null; } + public async Task AssociateScriptsToWalletExplicitly(TrackedSource trackedSource, + AssociateScriptRequest[] scripts) + { + var walletKey = GetWalletKey(trackedSource); + await using var conn = await GetConnection(); + + var scriptsRecords = scripts.Select(pair => new ScriptInsert(this.Network.CryptoCode, walletKey.wid, + pair.GetScriptPubKey().ToHex(), pair.GetAddress(Network.NBitcoinNetwork)?.ToString() ?? string.Empty, true)).ToArray(); + { + await conn.Connection.ExecuteAsync(WalletInsertQuery, new { walletKey.wid, walletKey.metadata }); + await conn.Connection.ExecuteAsync( + "INSERT INTO scripts (code, script, addr, used) VALUES(@code, @script, @addr, @used) ON CONFLICT DO NOTHING;" + + "INSERT INTO wallets_scripts (code, wallet_id, script) VALUES(@code, @wallet_id, @script) ON CONFLICT DO NOTHING;", scriptsRecords); + + if (ImportRPCMode.Legacy == await GetImportRPCMode(conn, walletKey)) + { + foreach (var s in scripts) + { + if (s.GetAddress(Network.NBitcoinNetwork) is { } addr) + await ImportAddressToRPC(null, addr, null); + } + } + } + } + internal record ScriptInsert(string code, string wallet_id, string script, string addr, bool used); internal record DescriptorScriptInsert(string descriptor, int idx, string script, string metadata, string addr, bool used); public async Task GenerateAddresses(DerivationStrategyBase strategy, DerivationFeature derivationFeature, GenerateAddressQuery query = null) @@ -1334,14 +1359,24 @@ public async Task EnsureWalletCreated(DerivationStrategyBase strategy) public async Task EnsureWalletCreated(TrackedSource trackedSource, params TrackedSource[] parentTrackedSource) { parentTrackedSource = parentTrackedSource.Where(source => source is not null).ToArray(); - await EnsureWalletCreated(GetWalletKey(trackedSource)); + await EnsureWalletCreated(GetWalletKey(trackedSource), parentTrackedSource?.Select(GetWalletKey).ToArray()); } record WalletHierarchyInsert(string child, string parent); - public async Task EnsureWalletCreated(WalletKey walletKey) + public async Task EnsureWalletCreated(WalletKey walletKey, WalletKey[] parentWallets = null) { await using var connection = await ConnectionFactory.CreateConnection(); - await connection.ExecuteAsync(WalletInsertQuery, walletKey); + parentWallets ??= Array.Empty(); + parentWallets = parentWallets.Where(p => p is not null).ToArray(); + var walletRecords = new[] { walletKey }.Concat(parentWallets).ToArray(); + var parentsRecords = parentWallets.Select(key => new WalletHierarchyInsert(walletKey.wid, key.wid)).ToArray(); + + + await connection.ExecuteAsync(WalletInsertQuery, walletRecords); + if (parentsRecords.Any()) + await connection.ExecuteAsync( + "INSERT INTO wallets_wallets (wallet_id, parent_id) VALUES (@child, @parent)ON CONFLICT DO NOTHING" + , parentsRecords); } private readonly string WalletInsertQuery = "INSERT INTO wallets (wallet_id, metadata) VALUES (@wid, @metadata::JSONB) ON CONFLICT DO NOTHING;"; diff --git a/NBXplorer/Controllers/MainController.cs b/NBXplorer/Controllers/MainController.cs index 0bb861a54..396bafc4a 100644 --- a/NBXplorer/Controllers/MainController.cs +++ b/NBXplorer/Controllers/MainController.cs @@ -76,7 +76,9 @@ public AddressPoolService AddressPoolService "getrawtransaction", "gettxout", "estimatesmartfee", - "getmempoolinfo" + "getmempoolinfo", + "gettxoutproof", + "verifytxoutproof" }; private Exception JsonRPCNotExposed() { @@ -515,6 +517,19 @@ public async Task TrackWallet( var trackedSource = trackedSourceContext.TrackedSource; var request = network.ParseJObject(rawRequest ?? new JObject()); + var repo = trackedSourceContext.Repository; + if (repo is PostgresRepository postgresRepository && + (trackedSourceContext.TrackedSource is WalletTrackedSource || + request?.ParentWallet is not null)) + { + if (request?.ParentWallet == trackedSourceContext.TrackedSource) + { + throw new NBXplorerException(new NBXplorerError(400, "parent-wallet-same-as-tracked-source", + "Parent wallets cannot be the same as the tracked source")); + } + await postgresRepository.EnsureWalletCreated(trackedSourceContext.TrackedSource, request?.ParentWallet); + } + if (trackedSource is DerivationSchemeTrackedSource dts) { if (request.Wait) @@ -1071,7 +1086,7 @@ public async Task GenerateWallet(TrackedSourceContext trackedSour AdditionalOptions = request.AdditionalOptions is not null ? new System.Collections.ObjectModel.ReadOnlyDictionary(request.AdditionalOptions) : null }); - await RepositoryProvider.GetRepository(network).EnsureWalletCreated(derivation); + await ((PostgresRepository)RepositoryProvider.GetRepository(network)).EnsureWalletCreated(new DerivationSchemeTrackedSource(derivation), new[] { request.ParentWallet }); var derivationTrackedSource = new DerivationSchemeTrackedSource(derivation); List saveMetadata = new List(); if (request.SavePrivateKeys) diff --git a/NBXplorer/Controllers/PostgresMainController.cs b/NBXplorer/Controllers/PostgresMainController.cs index ed1322b97..f74716769 100644 --- a/NBXplorer/Controllers/PostgresMainController.cs +++ b/NBXplorer/Controllers/PostgresMainController.cs @@ -85,6 +85,88 @@ public async Task GetBalance(TrackedSourceContext trackedSourceCo return Json(balance, network.JsonSerializerSettings); } + [HttpPost("associate")] + [PostgresImplementationActionConstraint(true)] + public async Task AssociateScripts(TrackedSourceContext trackedSourceContext, [FromBody] JArray rawRequest) + { + var repo = (PostgresRepository)trackedSourceContext.Repository; + var jsonSerializer = JsonSerializer.Create(trackedSourceContext.Network.JsonSerializerSettings); + var requests = rawRequest.ToObject(jsonSerializer); + await repo.AssociateScriptsToWalletExplicitly(trackedSourceContext.TrackedSource, requests); + return Ok(); + } + + [HttpPost("import-utxos")] + [TrackedSourceContext.TrackedSourceContextRequirement(true)] + public async Task ImportUTXOs(TrackedSourceContext trackedSourceContext, [FromBody] JObject rawRequest) + { + var repo = (PostgresRepository)trackedSourceContext.Repository; + var jsonSerializer = JsonSerializer.Create(trackedSourceContext.Network.JsonSerializerSettings); + var request = rawRequest.ToObject(jsonSerializer); + + if (request.Utxos?.Any() is not true) + return Ok(); + + var rpc = trackedSourceContext.RpcClient; + + var coinToTxOut = await rpc.GetTxOuts(request.Utxos); + var bestBlocks = await rpc.GetBlockHeadersAsync(coinToTxOut.Select(c => c.Value.BestBlock).ToHashSet().ToList()); + var coinsWithHeights = coinToTxOut + .Select(c => new + { + BestBlock = bestBlocks.ByHashes.TryGet(c.Value.BestBlock), + Outpoint = c.Key, + RPCTxOut = c.Value + }) + .Where(c => c.BestBlock is not null) + .Select(c => new + { + Height = c.BestBlock.Height - c.RPCTxOut.Confirmations + 1, + c.Outpoint, + c.RPCTxOut + }) + .ToList(); + var blockHeaders = await rpc.GetBlockHeadersAsync(coinsWithHeights.Where(c => c.RPCTxOut.Confirmations != 0).Select(c => c.Height).Distinct().ToList()); + + var scripts = coinToTxOut + .Select(pair => new AssociateScriptRequest() + { + ScriptPubKey = pair.Value.TxOut.ScriptPubKey + }) + .ToArray(); + + var now = DateTimeOffset.UtcNow; + var trackedTransactions = + coinsWithHeights + .Select(c => new + { + Block = blockHeaders.ByHeight.TryGet(c.Height), + c.Height, + c.RPCTxOut, + c.Outpoint + }) + .Where(c => c.Block is not null || c.RPCTxOut.Confirmations == 0) + .GroupBy(c => c.Outpoint.Hash) + .Select(g => + { + var coins = g.Select(c => new Coin(c.Outpoint, c.RPCTxOut.TxOut)).ToArray(); + var txInfo = g.First().RPCTxOut; + var block = g.First().Block; + var ttx = repo.CreateTrackedTransaction(trackedSourceContext.TrackedSource, + new TrackedTransactionKey(g.Key, block?.Hash, true) { }, coins, null); + ttx.Inserted = now; + ttx.Immature = txInfo.IsCoinBase && txInfo.Confirmations <= repo.Network.NBitcoinNetwork.Consensus.CoinbaseMaturity; + ttx.FirstSeen = block?.Time ?? now; + return ttx; + }).ToArray(); + + await repo.AssociateScriptsToWalletExplicitly(trackedSourceContext.TrackedSource, scripts); + await repo.SaveBlocks(blockHeaders.Select(b => b.ToSlimChainedBlock()).ToList()); + await repo.SaveMatches(trackedTransactions); + + return Ok(); + } + private IMoney Format(NBXplorerNetwork network, MoneyBag bag) { if (network.IsElement) @@ -185,5 +267,62 @@ public async Task GetUTXOs(TrackedSourceContext trackedSourceCont } return Json(changes, network.JsonSerializerSettings); } + + [HttpGet("children")] + public async Task GetWalletChildren(TrackedSourceContext trackedSourceContext) + { + var repo = (PostgresRepository)trackedSourceContext.Repository; + await using var conn = await ConnectionFactory.CreateConnection(); + var children = await conn.QueryAsync($"SELECT w.wallet_id, w.metadata FROM wallets_wallets ww JOIN wallets w ON ww.wallet_id = w.wallet_id WHERE ww.parent_id=@walletId", new { walletId = repo.GetWalletKey(trackedSourceContext.TrackedSource).wid }); + + return Json(children.Select(c => repo.GetTrackedSource(new PostgresRepository.WalletKey(c.wallet_id, c.metadata))).ToArray(), trackedSourceContext.Network.JsonSerializerSettings); + } + [HttpGet("parents")] + public async Task GetWalletParents(TrackedSourceContext trackedSourceContext) + { + var repo = (PostgresRepository)trackedSourceContext.Repository; + await using var conn = await ConnectionFactory.CreateConnection(); + var children = await conn.QueryAsync($"SELECT w.wallet_id, w.metadata FROM wallets_wallets ww JOIN wallets w ON ww.parent_id = w.wallet_id WHERE ww.wallet_id=@walletId", new { walletId = repo.GetWalletKey(trackedSourceContext.TrackedSource).wid }); + + return Json(children.Select(c => repo.GetTrackedSource(new PostgresRepository.WalletKey(c.wallet_id, c.metadata))).ToArray(), trackedSourceContext.Network.JsonSerializerSettings); + } + [HttpPost("children")] + public async Task AddWalletChild(TrackedSourceContext trackedSourceContext, [FromBody] JObject request) + { + var trackedSource = trackedSourceContext.Network.ParseJObject(request).TrackedSource; + var repo = (PostgresRepository)trackedSourceContext.Repository; + await repo.EnsureWalletCreated(trackedSource, trackedSourceContext.TrackedSource); + return Ok(); + } + [HttpPost("parents")] + public async Task AddWalletParent(TrackedSourceContext trackedSourceContext, [FromBody] JObject request) + { + var trackedSource = trackedSourceContext.Network.ParseJObject(request).TrackedSource; + var repo = (PostgresRepository)trackedSourceContext.Repository; + await repo.EnsureWalletCreated(trackedSourceContext.TrackedSource, trackedSource); + return Ok(); + } + [HttpDelete("children")] + public async Task RemoveWalletChild(TrackedSourceContext trackedSourceContext, [FromBody] JObject request) + { + var repo = (PostgresRepository)trackedSourceContext.Repository; + + var trackedSource = repo.GetWalletKey(trackedSourceContext.Network + .ParseJObject(request).TrackedSource); + var conn = await ConnectionFactory.CreateConnection(); + await conn.ExecuteAsync($"DELETE FROM wallets_wallets WHERE wallet_id=@walletId AND parent_id=@parentId", new { walletId = trackedSource.wid, parentId = repo.GetWalletKey(trackedSourceContext.TrackedSource).wid }); + return Ok(); + } + [HttpDelete("parents")] + public async Task RemoveWalletParent(TrackedSourceContext trackedSourceContext, [FromBody] JObject request) + { + var repo = (PostgresRepository)trackedSourceContext.Repository; + + var trackedSource = repo.GetWalletKey(trackedSourceContext.Network + .ParseJObject(request).TrackedSource); + var conn = await ConnectionFactory.CreateConnection(); + await conn.ExecuteAsync($"DELETE FROM wallets_wallets WHERE wallet_id=@walletId AND parent_id=@parentId", new { walletId = repo.GetWalletKey(trackedSourceContext.TrackedSource).wid, parentId = trackedSource.wid }); + return Ok(); + } } } diff --git a/NBXplorer/RPCClientExtensions.cs b/NBXplorer/RPCClientExtensions.cs index b5a5384c1..0e7da27aa 100644 --- a/NBXplorer/RPCClientExtensions.cs +++ b/NBXplorer/RPCClientExtensions.cs @@ -248,6 +248,13 @@ public static async Task UTXOUpdatePSBT(this RPCClient rpcClient, PSBT psb throw new Exception("This should never happen"); } + public static async Task GetBlockHeadersAsync(this RPCClient rpc, IList hashes) + { + var batch = rpc.PrepareBatch(); + var headers = hashes.Select(h => batch.GetBlockHeaderAsyncEx(h)).ToArray(); + await batch.SendBatchAsync(); + return new BlockHeaders(headers.Select(h => h.GetAwaiter().GetResult()).Where(h => h is not null).ToList()); + } public static async Task GetBlockHeadersAsync(this RPCClient rpc, IList blockHeights) { var batch = rpc.PrepareBatch(); @@ -260,7 +267,23 @@ public static async Task GetBlockHeadersAsync(this RPCClient rpc, return new BlockHeaders(headers.Select(h => h.GetAwaiter().GetResult()).Where(h => h is not null).ToList()); } - + public static async Task> GetTxOuts(this RPCClient rpc, IList outpoints) + { + var batch = rpc.PrepareBatch(); + var txOuts = outpoints.Select(o => batch.GetTxOutAsync(o.Hash, (int)o.N, true)).ToArray(); + await batch.SendBatchAsync(); + var result = new Dictionary(); + int i = 0; + foreach (var txOut in txOuts) + { + var outpoint = outpoints[i]; + var r = await txOut; + if (r != null) + result.TryAdd(outpoint, r); + i++; + } + return result; + } public static async Task GetBlockHeaderAsyncEx(this RPCClient rpc, uint256 blk) { var header = await rpc.SendCommandAsync(new NBitcoin.RPC.RPCRequest("getblockheader", new[] { blk.ToString() }) diff --git a/docs/API.md b/docs/API.md index 644d9ff10..b69e530c5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -8,19 +8,20 @@ NBXplorer does not index the whole blockchain, rather, it listens transactions a * [Configuration](#configuration) * [Authentication](#authentication) +* [Tracked Sources](#tracked-source) +* [Child Wallets](#child-wallets) * [Derivation Scheme Format](#derivationScheme) * [Tracking a Derivation Scheme](#track) * [Track a specific address](#address) -* [Query transactions associated to a Derivation Scheme](#transactions) -* [Query transactions associated to a specific address](#address-transactions) -* [Query a single transaction associated to a address or derivation scheme](#singletransaction) +* [Track a wallet](#wallet) +* [Query transactions associated to a tracked source](#transactions) +* [Query a single transaction associated to a tracked source](#singletransaction) * [Get current balance](#balance) * [Get a transaction](#gettransaction) * [Get connection status to the chain](#status) * [Get a new unused address](#unused) * [Get scriptPubKey information of a Derivation Scheme](#scriptPubKey) * [Get available Unspent Transaction Outputs (UTXOs)](#utxos) -* [Get available Unspent Transaction Outputs of a specific address](#address-utxos) * [Notifications via websocket](#websocket) * [Broadcast a transaction](#broadcast) * [Rescan a transaction](#rescan) @@ -39,6 +40,7 @@ NBXplorer does not index the whole blockchain, rather, it listens transactions a * [Node RPC Proxy](#rpc-proxy) * [Health check](#health) * [Liquid integration](#liquid) +* [Hierarchy APIs](#hierarchy) ## Configuration @@ -90,9 +92,28 @@ This can be disabled with `--noauth`. Also, NBXPlorer listen by default on `127.0.0.1`, if you want to access it from another machine, run `--bind "0.0.0.0"`. +## Tracked Sources + +A tracked source is a generic way to track a set of scripts (addresses) and its UTXOs, transactions, and balances. + +Currently, a tracked source can be: +* A derivation scheme, often referred to as an xpub. This method allows automatic, deterministic generation of scripts to track. The format for a derivation scheme tracked source identifier is `DERIVATIIONSCHEME:xpub1`, where xpub1 follows the [derivation scheme format](#derivationScheme). +* A specific address. This method allows tracking of a single bitcoin address and its script. The format for an address tracked source identifier is `ADDRESS:address1`, where address1 is a valid bitcoin address. +* A wallet. Postgres only. This method allows tracking of a set of scripts, but does not automatically generate new scripts, so they must be [added manually](#associate-scripts). The format for a wallet tracked source identifier is `WALLET:wallet1`, where wallet1 is an arbitrary string. + +Postgres only features: +While each of these has its own logic around what scripts to follow, they all allow adding additional, seemingly unrelated scripts to track, through the use of the [associate scripts API](#associate-scripts). + +## Child Wallets + +When using Postgres, the feature of child wallets allows you to link a tracked source as a child of another tracked source. +Every script generated by a child wallet will be tracked as part of its parent wallet, including all related UTXOs and transactions. +A parent can have an unlimited number of child wallets, and child wallets themselves can act as parents to other wallets. +You can manage and view the hierarchy of tracked sources using the [hierarchy APIs](#hierarchy). + ## Derivation Scheme Format -A derivation scheme, also called derivationStrategy in the code, is a flexible way to define how to generate address of a wallet. +A derivation scheme, also called derivationStrategy in the code, is a flexible way to define how to generate deterministic addresses for a wallet. NBXplorer will track any addresses on the `0/x`, `1/x` and `x` path. Here a documentation of the different derivation scheme supported: @@ -134,15 +155,17 @@ Optionally, you can attach a json body: "maxAddresses": null } ], - "wait": true + "wait": true, + "parentWallet": "DERIVATIONSCHEME:xpub" } ``` * `wait`: Optional. If `true` the call will return when all addresses has been generated, addresses will be generated in the background (default: `false`) * `derivationOptions`: Optional. Options to manually start the address generation process. (default: empty) -* `derivationOptions.feature`: Optional. Define to which feature this option should be used. (defaut: null, which match all feature) +* `derivationOptions.feature`: Optional. Define to which feature this option should be used. (default: null, which match all feature) * `derivationOptions.minAddresses`: Optional. The minimum addresses that need to be generated with this call. (default: null, make sure the number of address in the pool is between MinGap and MaxGap) * `derivationOptions.maxAddresses`: Optional. The maximum addresses that need to be generated with this call. (default: null, make sure the number of address in the pool is between MinGap and MaxGap) +* `parentWallet`: Optional. Postgres only. If specified, the derivation scheme will be tracked as a [child wallet](#child-wallet) of the specified [tracked source](#tracked-source). (default: null) ## Track a specific address @@ -152,15 +175,43 @@ After this call, the specified address will be tracked by NBXplorer Returns nothing. -## Query transactions associated to a derivationScheme +Optionally, you can attach a json body: + +```json +{ + "parentWallet": "DERIVATIONSCHEME:xpub" +} +``` + +* `parentWallet`: Optional. Postgres only. If specified, the address will be tracked as a [child wallet](#child-wallet) of the specified [tracked source](#tracked-source). (default: null) + + +## Track a wallet + +When using Postgres, you can define wallets using an arbitrary wallet id, that tracks nothing by default. You can then add [tracked sources](#tracked-source) as child wallets of this wallet, which will make it inherit all their scripts and utxos, or you can [associate scripts](#associate-scripts) to it manually. + +`HTTP POST v1/cryptos/{cryptoCode}/wallets/{walletId}` + +Returns nothing. + +Optionally, you can attach a json body: + +```json +{ + "parentWallet": "DERIVATIONSCHEME:xpub" +} +``` + -To query all transactions of a `derivation scheme`: +* `parentWallet`: Optional. Postgres only. If specified, the wallet will be tracked as a [child wallet](#child-wallet) of the specified [tracked source](#tracked-source). (default: null) -`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/transactions` +## Query transactions associated to a tracked source -To query a specific transaction: +To query all transactions of a `tracked source`: -`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/transactions/{txId}` +`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/transactions`
+`HTTP GET v1/cryptos/{cryptoCode}/addresses/{address}/transactions`
+`HTTP GET v1/cryptos/{cryptoCode}/wallets/{walletId}/transactions` Optional Parameters: @@ -262,73 +313,12 @@ Returns: Note for liquid, `balanceChange` is an array of [AssetMoney](#liquid). Note that the list of confirmed transaction also include immature transactions. -## Query transactions associated to a specific address - -Query all transactions of a tracked address. (Only work if you called the Track operation on this specific address) - -`HTTP GET v1/cryptos/{cryptoCode}/addresses/{address}/transactions` - -Optional Parameters: - -* `includeTransaction` includes the hex of the transaction, not only information (default: true) - -Returns: - -```json -{ - "height": 104, - "confirmedTransactions": { - "transactions": [ - { - "blockHash": "3e7bcca309f92ab78a47c1cdd1166de9190fa49e97165c93e2b10ae1a14b99eb", - "confirmations": 1, - "height": 104, - "transactionId": "cc33dfaf2ed794b11af83dc6e29303e2d8ff9e5e29303153dad1a1d3d8b43e40", - "transaction": "020000000166d6befa387fd646f77a10e4b0f0e66b3569f18a83f77104a0c440e4156f80890000000048473044022064b1398653171440d3e79924cb6593633e7b2c3d80b60a2e21d6c6e287ee785a02203899009df443d0a0a1b06cb970aee0158d35166fd3e26d4e3e85570738e706d101feffffff028c02102401000000160014ee0a1889783da2e1f9bba47be4184b6610efd00400e1f5050000000016001452f88af314ef3b6d03d40a5fd1f2c906188a477567000000", - "outputs": [ - { - "scriptPubKey": "001452f88af314ef3b6d03d40a5fd1f2c906188a4775", - "index": 1, - "value": 100000000 - } - ], - "inputs": [], - "timestamp": 1540381888, - "balanceChange": 100000000 - } - ] - }, - "unconfirmedTransactions": { - "transactions": [ - { - "blockHash": null, - "confirmations": 0, - "height": null, - "transactionId": "7ec0bcbd3b7685b6bbdb4287a250b64bfcb799dbbbcffa78c00e6cc11185e5f1", - "transaction": null, - "outputs": [ - { - "scriptPubKey": "0014b39fc4eb5c6dd238d39449b70a2e30d575426d99", - "index": 1, - "value": 100000000 - } - ], - "inputs": [], - "timestamp": 1540381889, - "balanceChange": 100000000 - } - ] - }, - "replacedTransactions": { - "transactions": [] - } -} -``` -## Query a single transaction associated to a address or derivation scheme +## Query a single transaction associated to a tracked source -`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/transactions/{txId}` -`HTTP GET v1/cryptos/{cryptoCode}/addresses/{address}/transactions/{txId}` +`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/transactions/{txId}`
+`HTTP GET v1/cryptos/{cryptoCode}/addresses/{address}/transactions/{txId}`
+`HTTP GET v1/cryptos/{cryptoCode}/wallets/{walletId}/transactions/{txId}` Error codes: @@ -362,7 +352,9 @@ Returns: ## Get current balance -`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/balance` +`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/balance`
+`HTTP GET v1/cryptos/{cryptoCode}/addresses/{address}/balance`
+`HTTP GET v1/cryptos/{cryptoCode}/wallets/{walletId}/balance` Returns: @@ -483,10 +475,12 @@ Returns: Note: `redeem` is returning the segwit redeem if the derivation scheme is a P2SH-P2WSH or P2WSH, or the p2sh redeem if just a p2sh. -## Get scriptPubKey information of a Derivation Scheme - -`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/scripts/{script}` +## Get scriptPubKey information of a tracked source +`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/scripts/{script}`
+`HTTP GET v1/cryptos/{cryptoCode}/addresses/{address}/scripts/{script}`
+`HTTP GET v1/cryptos/{cryptoCode}/wallets/{walletId}/scripts/{script}`
+`HTTP GET v1/cryptos/{cryptoCode}/tracked-sources/{trackedSource}/scripts/{script}` Error codes: * HTTP 404: `cryptoCode-not-supported` @@ -506,7 +500,10 @@ Returns: ## Get available Unspent Transaction Outputs (UTXOs) -`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/utxos` +`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/utxos`
+`HTTP GET v1/cryptos/{cryptoCode}/addresses/{address}/utxos`
+`HTTP GET v1/cryptos/{cryptoCode}/wallets/{walletId}/utxos`
+`HTTP GET v1/cryptos/{cryptoCode}/tracked-sources/{trackedSource}/utxos` Error: @@ -616,27 +613,11 @@ Result: "value": 100000000, "timestamp": 1540390664, "confirmations": 2 - }, - { - "outpoint": "a470a71144d4cdaef2b9bd8d24f20ebc8d6548bae523869f8cceb2cef5b4538a01000000", - "index": 1, - "transactionHash": "8a53b4f5ceb2ce8c9f8623e5ba48658dbc0ef2248dbdb9f2aecdd44411a770a4", - "scriptPubKey": "76a9145461f6c342451142e07d95dd2a42b48af9114cea88ac", - "value": 100000000, - "timestamp": 1540390666, - "confirmations": 1 - }, - { - "outpoint": "1710a1b61cb1f988182347be52a16502bae5a78fa9740a68107f9ddc6e30896a00000000", - "index": 0, - "transactionHash": "6a89306edc9d7f10680a74a98fa7e5ba0265a152be47231888f9b11cb6a11017", - "scriptPubKey": "76a9145461f6c342451142e07d95dd2a42b48af9114cea88ac", - "value": 60000000, - "timestamp": 1540390666, - "confirmations": 1 } ], - "spentOutpoints": [], + "spentOutpoints": [ + "9345f9585d643a31202e686ec7a4c2fe17917a5e7731a79d2327d24d25c0339f01000000" + ], "hasChanges": true }, "hasChanges": true @@ -644,6 +625,7 @@ Result: ``` This call does not returns conflicted unconfirmed UTXOs. +Note that confirmed utxo, do not include immature UTXOs. (ie. UTXOs belonging to a coinbase transaction with less than 100 confirmations) ## Notifications via websocket @@ -973,7 +955,7 @@ The smallest `eventId` is 1. "value": 100000000 } ], - "cryptoCode": "BTC", + "cryptoCode": "BTC" } } ] @@ -1067,7 +1049,7 @@ Fields: * `destinations[].destination`: Required, the destination address * `destinations[].amount` Send this amount to the destination (Mutually exclusive with: sweepAll) * `destinations[].substractFees` Default to false, will substract the fees of this transaction to this destination (Mutually exclusive with: sweepAll) -* `destinations[].sweepAll` Deault to false, will sweep all the balance of your wallet to this destination (Mutually exclusive with: amount, substractFees) +* `destinations[].sweepAll` Default to false, will sweep all the balance of your wallet to this destination (Mutually exclusive with: amount, substractFees) * `feePreference`: Optional, determines how fees for the transaction are calculated, default to the full node estimation for 1 block target. * `feePreference.explicitFeeRate`: An explicit fee rate for the transaction in Satoshi per vBytes (Mutually exclusive with: blockTarget, explicitFee, fallbackFeeRate) * `feePreference.explicitFee`: An explicit fee for the transaction in Satoshi (Mutually exclusive with: blockTarget, explicitFeeRate, fallbackFeeRate) @@ -1227,7 +1209,8 @@ Request: "passphrase": "hello", "importKeysToRPC": true, "savePrivateKeys": true, - "additionalOptions": { "slip77": "6c2de18eabeff3f7822bc724ad482bef0557f3e1c1e1c75b7a393a5ced4de616"} + "additionalOptions": { "slip77": "6c2de18eabeff3f7822bc724ad482bef0557f3e1c1e1c75b7a393a5ced4de616"}, + "parentWallet": "WALLET:xyz" } ``` @@ -1240,6 +1223,7 @@ Request: * `importKeysToRPC`: Optional, if true, every times a call to [get a new unused address](#unused) is called, the private key will be imported into the underlying node via RPC's `importprivkey`. (Default: `false`) * `savePrivateKeys`: If true, private keys will be saved inside the following metadata `Mnemonic`, `MasterHDKey` and `AccountHDKey`. * `additionalOptions`: Optional, additional options that a derivation scheme of some networks may support, such as [Liquid](#liquid) +* `parentWallet`: Optional. Postgres only. If specified, the derivation scheme will be tracked as a [child wallet](#child-wallet) of the specified [tracked source](#tracked-source). (default: null) The `importKeysToRPC` is only useful if one need to manage his wallet via the node's cli tooling. @@ -1317,6 +1301,168 @@ Response: NOTE: Batch commands are also supported by sending the JSON-RPC requests in an array. The result is also returned in an array. +## Associate scripts to wallet + +Note: This API is only available for Postgres. + +`HTTP POST v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/associate`
+`HTTP POST v1/cryptos/{cryptoCode}/addresses/{address}/associate`
+`HTTP POST v1/cryptos/{cryptoCode}/addresses/{address}/associate`
+`HTTP POST v1/cryptos/{cryptoCode}/wallets/{wallet}/associate`
+`HTTP POST v1/cryptos/{cryptoCode}/tracked-sources/{trackedSource}/associate` + +Request: + +Associate a script to a wallet, either with a bitcoin address via `destination`, or the raw script via `scriptPubKey`. + +* `destination`: The address to add to the wallet +* `scriptPubKey`: The scriptPubKey to add to the wallet (Ignored if `destination` is set) + +```json +[ + { + "destinatiom": "bc1q...", + "scriptPubKey": "0014..." + } +] +``` + + +## Import UTXOs to wallet + +Note: This API is only available for Postgres. + +In the case where you start tracking a wallet that already has UTXOs, you can import them to NBXplorer so that it can be aware of them. NBXplorer will validate against the bitcoin node's utxoset that the UTXOs are valid. Additionally, you can also provide merkle proofs that the UTXOs were included in a block. + +`HTTP POST v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/import-utxos`
+`HTTP POST v1/cryptos/{cryptoCode}/addresses/{address}/import-utxos`
+`HTTP POST v1/cryptos/{cryptoCode}/addresses/{address}/import-utxos`
+`HTTP POST v1/cryptos/{cryptoCode}/wallets/{wallet}/import-utxos`
+`HTTP POST v1/cryptos/{cryptoCode}/tracked-sources/{trackedSource}/import-utxos` + +Request: + + +* `UTXOs`: Mandatory, the utxos to import, in the format `txid-vout`, where `txid` is the transaction id and `vout` is the output index + +```json + { + "UTXOs": ["c8fd6675624d0b88056b9eaf945c5fd0c4614f7ddf44eb81911b3a66ba0e57a0-0"] + } +``` + +No response body + + +## Hierarchy APIs + +Note: These APIs are only available for Postgres. + +### View wallet parents + +`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/parents`
+`HTTP GET v1/cryptos/{cryptoCode}/addresses/{address}/parents`
+`HTTP GET v1/cryptos/{cryptoCode}/addresses/{address}/parents`
+`HTTP GET v1/cryptos/{cryptoCode}/wallets/{wallet}/parents`
+`HTTP GET v1/cryptos/{cryptoCode}/tracked-sources/{trackedSource}/parents` + +Response: an array of tracked sources + +```json +[ + "WALLET:xyz", + "DERIVATIONSCHEME:xpub...", + "ADDRESS:xyz" +] +``` + + +### Add wallet parent + +`HTTP POST v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/parents`
+`HTTP POST v1/cryptos/{cryptoCode}/addresses/{address}/parents`
+`HTTP POST v1/cryptos/{cryptoCode}/addresses/{address}/parents`
+`HTTP POST v1/cryptos/{cryptoCode}/wallets/{wallet}/parents`
+`HTTP POST v1/cryptos/{cryptoCode}/tracked-sources/{trackedSource}/parents` + +Request: a json string of the tracked source to add + +```json +"WALLET:xyz" +``` + +No response body + + +### Remove wallet parent + +`HTTP DELETE v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/parents`
+`HTTP DELETE v1/cryptos/{cryptoCode}/addresses/{address}/parents`
+`HTTP DELETE v1/cryptos/{cryptoCode}/addresses/{address}/parents`
+`HTTP DELETE v1/cryptos/{cryptoCode}/wallets/{wallet}/parents`
+`HTTP DELETE v1/cryptos/{cryptoCode}/tracked-sources/{trackedSource}/parents` + +Request: a json string of the tracked source to remove + +```json +"WALLET:xyz" +``` + +No response body + + + +### View wallet children + +`HTTP GET v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/children`
+`HTTP GET v1/cryptos/{cryptoCode}/addresses/{address}/children`
+`HTTP GET v1/cryptos/{cryptoCode}/addresses/{address}/children`
+`HTTP GET v1/cryptos/{cryptoCode}/wallets/{wallet}/children`
+`HTTP GET v1/cryptos/{cryptoCode}/tracked-sources/{trackedSource}/children` + +Response: an array of tracked sources + +```json +[ + "WALLET:xyz", + "DERIVATIONSCHEME:xpub...", + "ADDRESS:xyz" +] +``` + +### Add wallet child + +`HTTP POST v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/children`
+`HTTP POST v1/cryptos/{cryptoCode}/addresses/{address}/children`
+`HTTP POST v1/cryptos/{cryptoCode}/addresses/{address}/children`
+`HTTP POST v1/cryptos/{cryptoCode}/wallets/{wallet}/children`
+`HTTP POST v1/cryptos/{cryptoCode}/tracked-sources/{trackedSource}/children` + +Request: a json string of the tracked source to add + +```json +"WALLET:xyz" +``` + +No response body + +### Remove wallet child + +`HTTP DELETE v1/cryptos/{cryptoCode}/derivations/{derivationScheme}/children`
+`HTTP DELETE v1/cryptos/{cryptoCode}/addresses/{address}/children`
+`HTTP DELETE v1/cryptos/{cryptoCode}/addresses/{address}/children`
+`HTTP DELETE v1/cryptos/{cryptoCode}/wallets/{wallet}/children`
+`HTTP DELETE v1/cryptos/{cryptoCode}/tracked-sources/{trackedSource}/children` + +Request: a json string of the tracked source to remove + +```json +"WALLET:xyz" +``` + +No response body + + ## Health check A endpoint that can be used without the need for [authentication](#auth) which will returns HTTP 200 only if all nodes connected to NBXplorer are ready. @@ -1366,3 +1512,4 @@ In order to send in and out of liquid, we advise you to rely on the RPC command For doing this you need to [Generate a wallet](#wallet) with `importAddressToRPC` and `savePrivateKeys` set to `true`. Be careful to not expose your NBXplorer server on internet, your private keys can be [retrieved trivially](#getmetadata). +