diff --git a/NBXplorer.Client/ExplorerClient.cs b/NBXplorer.Client/ExplorerClient.cs index 78d5f0281..de6bd02f1 100644 --- a/NBXplorer.Client/ExplorerClient.cs +++ b/NBXplorer.Client/ExplorerClient.cs @@ -547,6 +547,28 @@ public GenerateWalletResponse GenerateWallet(GenerateWalletRequest request = nul return GenerateWalletAsync(request, cancellationToken).GetAwaiter().GetResult(); } + public Task CreateGroupAsync(CancellationToken cancellationToken = default) + { + return SendAsync(HttpMethod.Post, null, $"v1/groups", cancellationToken); + } + public Task GetGroupAsync(string groupId, CancellationToken cancellationToken = default) + { + return SendAsync(HttpMethod.Get, null, $"v1/groups/{groupId}", cancellationToken); + } + public Task AddGroupChildrenAsync(string groupId, GroupChild[] children, CancellationToken cancellationToken = default) + { + return SendAsync(HttpMethod.Post, children, $"v1/groups/{groupId}/children", cancellationToken); + } + public Task RemoveGroupChildrenAsync(string groupId, GroupChild[] children, CancellationToken cancellationToken = default) + { + return SendAsync(HttpMethod.Delete, children, $"v1/groups/{groupId}/children", cancellationToken); + } + + public Task AddGroupAddressAsync(string cryptoCode, string groupId, string[] addresses, CancellationToken cancellationToken = default) + { + return SendAsync(HttpMethod.Post, addresses, $"v1/cryptos/{cryptoCode}/groups/{groupId}/addresses", cancellationToken); + } + private static readonly HttpClient SharedClient = new HttpClient(); internal HttpClient Client = SharedClient; diff --git a/NBXplorer.Client/Models/GenerateWalletResponse.cs b/NBXplorer.Client/Models/GenerateWalletResponse.cs index 709f9a419..7a180b72e 100644 --- a/NBXplorer.Client/Models/GenerateWalletResponse.cs +++ b/NBXplorer.Client/Models/GenerateWalletResponse.cs @@ -6,6 +6,7 @@ namespace NBXplorer.Models { public class GenerateWalletResponse { + public string TrackedSource { get; set; } public string Mnemonic { get; set; } public string Passphrase { get; set; } [JsonConverter(typeof(NBXplorer.JsonConverters.WordlistJsonConverter))] diff --git a/NBXplorer.Client/Models/GroupInformation.cs b/NBXplorer.Client/Models/GroupInformation.cs new file mode 100644 index 000000000..136b5ee57 --- /dev/null +++ b/NBXplorer.Client/Models/GroupInformation.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Text; + +namespace NBXplorer.Models +{ + public class GroupChild + { + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public string CryptoCode { get; set; } + public string TrackedSource { get; set; } + } + public class GroupInformation + { + public string TrackedSource { get; set; } + public string GroupId { get; set; } + public GroupChild[] Children { get; set; } + + public GroupChild AsGroupChild() => new () { TrackedSource = TrackedSource }; + } +} diff --git a/NBXplorer.Client/Models/TrackedSource.cs b/NBXplorer.Client/Models/TrackedSource.cs index 398fc4f2c..9589b5a2b 100644 --- a/NBXplorer.Client/Models/TrackedSource.cs +++ b/NBXplorer.Client/Models/TrackedSource.cs @@ -1,6 +1,8 @@ using NBitcoin; +using NBitcoin.DataEncoders; using NBXplorer.DerivationStrategy; using System; +using System.Security.Cryptography; namespace NBXplorer.Models { @@ -10,18 +12,20 @@ public static bool TryParse(string str, out TrackedSource trackedSource, NBXplor { if (str == null) throw new ArgumentNullException(nameof(str)); - if (network == null) - throw new ArgumentNullException(nameof(network)); trackedSource = null; var strSpan = str.AsSpan(); if (strSpan.StartsWith("DERIVATIONSCHEME:".AsSpan(), StringComparison.Ordinal)) { + if (network is null) + return false; if (!DerivationSchemeTrackedSource.TryParse(strSpan, out var derivationSchemeTrackedSource, network)) return false; trackedSource = derivationSchemeTrackedSource; } else if (strSpan.StartsWith("ADDRESS:".AsSpan(), StringComparison.Ordinal)) { + if (network is null) + return false; if (!AddressTrackedSource.TryParse(strSpan, out var addressTrackedSource, network.NBitcoinNetwork)) return false; trackedSource = addressTrackedSource; @@ -107,6 +111,14 @@ public class GroupTrackedSource : TrackedSource { public string GroupId { get; } + public static GroupTrackedSource Generate() + { + Span r = stackalloc byte[13]; + // 13 is most consistent on number of chars and more than we need to avoid generating twice same id + RandomNumberGenerator.Fill(r); + return new GroupTrackedSource(Encoders.Base58.EncodeData(r)); + } + public GroupTrackedSource(string groupId) { GroupId = groupId; @@ -133,7 +145,7 @@ public override string ToString() } public override string ToPrettyString() { - return GroupId; + return "G:" + GroupId; } public static GroupTrackedSource Parse(string trackedSource) diff --git a/NBXplorer.Tests/UnitTest1.Groups.cs b/NBXplorer.Tests/UnitTest1.Groups.cs new file mode 100644 index 000000000..5b7a1a376 --- /dev/null +++ b/NBXplorer.Tests/UnitTest1.Groups.cs @@ -0,0 +1,136 @@ +using Dapper; +using Microsoft.AspNetCore.Mvc.ViewFeatures.Infrastructure; +using NBitcoin; +using NBXplorer.Backends.Postgres; +using NBXplorer.Controllers; +using NBXplorer.Models; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace NBXplorer.Tests +{ + public partial class UnitTest1 + { + [Fact] + public async Task CanCRUDGroups() + { + using var tester = ServerTester.Create(Backend.Postgres); + var g1 = await tester.Client.CreateGroupAsync(); + void AssertG1Empty() + { + Assert.NotNull(g1.GroupId); + Assert.NotNull(g1.TrackedSource); + Assert.Equal($"GROUP:{g1.GroupId}", g1.TrackedSource); + Assert.Empty(g1.Children); + } + AssertG1Empty(); + g1 = await tester.Client.GetGroupAsync(g1.GroupId); + AssertG1Empty(); + Assert.Null(await tester.Client.GetGroupAsync("lol")); + Assert.Null(await tester.Client.AddGroupChildrenAsync("lol", Array.Empty())); + + await AssertNBXplorerException(409, tester.Client.AddGroupChildrenAsync(g1.GroupId, [g1.AsGroupChild()])); + Assert.Null(await tester.Client.AddGroupChildrenAsync(g1.GroupId, [new GroupChild() { TrackedSource = "GROUP:Test" }])); + Assert.Null(await tester.Client.AddGroupChildrenAsync("Test", [new GroupChild() { TrackedSource = g1.TrackedSource }])); + + var g2 = await tester.Client.CreateGroupAsync(); + g1 = await tester.Client.AddGroupChildrenAsync(g1.GroupId, [g2.AsGroupChild()]); + Assert.NotNull(g1); + // Nothing happen if twice + g1 = await tester.Client.AddGroupChildrenAsync(g1.GroupId, [g2.AsGroupChild()]); + Assert.Equal(g2.TrackedSource, Assert.Single(g1.Children).TrackedSource); + await AssertNBXplorerException(409, tester.Client.AddGroupChildrenAsync(g2.GroupId, [g1.AsGroupChild()])); + g1 = await tester.Client.RemoveGroupChildrenAsync(g1.GroupId, [g2.AsGroupChild()]); + AssertG1Empty(); + + var g3 = await tester.Client.CreateGroupAsync(); + g1 = await tester.Client.AddGroupChildrenAsync(g1.GroupId, [g2.AsGroupChild(), g3.AsGroupChild()]); + Assert.Equal(2, g1.Children.Length); + + // Adding address in g2 should add the addresse to g1 but not g3 + var addresses = Enumerable.Range(0,10).Select(_ => new Key().GetAddress(ScriptPubKeyType.Legacy, tester.Network).ToString()).ToArray(); + await tester.Client.AddGroupAddressAsync("BTC", g2.GroupId, addresses); + // Idempotent + await tester.Client.AddGroupAddressAsync("BTC", g2.GroupId, addresses); + + async Task AssertAddresses(GroupInformation g) + { + var groupAddresses = await GetGroupAddressesAsync(tester, "BTC", g.GroupId); + Assert.Equal(groupAddresses.Length, addresses.Length); + foreach (var a in addresses) + { + Assert.Contains(a, groupAddresses); + } + } + await AssertAddresses(g1); + await AssertAddresses(g2); + var g3Addrs = await GetGroupAddressesAsync(tester, "BTC", g3.GroupId); + Assert.Empty(g3Addrs); + + // Removing g2 should remove all its addresses + g1 = await tester.Client.RemoveGroupChildrenAsync(g1.GroupId, [g2.AsGroupChild()]); + await AssertAddresses(g2); + var g1Addrs = await GetGroupAddressesAsync(tester, "BTC", g1.GroupId); + Assert.Empty(g1Addrs); + + await AssertNBXplorerException(400, tester.Client.AddGroupChildrenAsync(g2.GroupId, [new GroupChild() { TrackedSource= "DERIVATIONSCHEME:tpubDC45vUDsFAAqwYKz5hSLi5yJLNduJzpmTw6QTMRPrwdXURoyL81H8oZAaL8EiwEgg92qgMa9h1bB4Y1BZpy9CTNPfjfxvFcWxeiKBHCqSdc" }])); + await AssertNBXplorerException(400, tester.Client.AddGroupChildrenAsync(g2.GroupId, [new GroupChild() { CryptoCode="BTC", TrackedSource = "DERIVATIONSCHEME:lol" }])); + } + + private async Task GetGroupAddressesAsync(ServerTester tester, string code, string groupId) + { + await using var conn = await tester.GetService().CreateConnection(); + return (await conn.QueryAsync("SELECT s.addr FROM wallets_scripts JOIN scripts s USING (code, script) WHERE code=@code AND wallet_id=@wid", new + { + code = code, + wid = PostgresRepository.GetWalletKey(new GroupTrackedSource(groupId)).wid + })).ToArray(); + } + + [Fact] + public async Task CanAliceAndBobShareWallet() + { + using var tester = ServerTester.Create(Backend.Postgres); + var bobW = tester.Client.GenerateWallet(new GenerateWalletRequest() { ScriptPubKeyType = ScriptPubKeyType.Segwit }); + var aliceW = tester.Client.GenerateWallet(new GenerateWalletRequest() { ScriptPubKeyType = ScriptPubKeyType.Segwit }); + + var shared = await tester.Client.CreateGroupAsync(); + await tester.Client.AddGroupChildrenAsync(shared.GroupId, new[] { bobW, aliceW }.Select(w => new GroupChild() { CryptoCode = "BTC", TrackedSource = w.TrackedSource }).ToArray()); + + var unused = tester.Client.GetUnused(bobW.DerivationScheme, DerivationStrategy.DerivationFeature.Deposit); + var txid = tester.SendToAddress(unused.Address, Money.Coins(1.0m)); + var gts = GroupTrackedSource.Parse(shared.TrackedSource); + tester.Notifications.WaitForTransaction(gts, txid); + + var balance = await tester.Client.GetBalanceAsync(gts); + Assert.Equal(Money.Coins(1.0m), balance.Unconfirmed); + + var txs = await tester.Client.GetTransactionsAsync(gts); + var tx = Assert.Single(txs.UnconfirmedTransactions.Transactions); + Assert.Equal(txid, tx.TransactionId); + Assert.NotNull(tx.Outputs[0].Address); + + // Can we track manually added address? + await tester.Client.AddGroupAddressAsync("BTC", shared.GroupId, ["n3XyBWEKWLxm5EzrrvLCJyCQrRhVWQ8YGa"]); + txid = tester.SendToAddress(BitcoinAddress.Create("n3XyBWEKWLxm5EzrrvLCJyCQrRhVWQ8YGa", tester.Network), Money.Coins(1.2m)); + var txEvt = tester.Notifications.WaitForTransaction(gts, txid); + Assert.Single(txEvt.Outputs); + Assert.NotNull(tx.Outputs[0].Address); + + balance = await tester.Client.GetBalanceAsync(gts); + Assert.Equal(Money.Coins(1.0m + 1.2m), balance.Unconfirmed); + } + + private async Task AssertNBXplorerException(int httpCode, Task task) + { + var ex = await Assert.ThrowsAsync(() => task); + Assert.Equal(httpCode, ex.Error.HttpCode); + return ex; + } + } +} diff --git a/NBXplorer.Tests/UnitTest1.cs b/NBXplorer.Tests/UnitTest1.cs index 51de584db..8a5d7c88e 100644 --- a/NBXplorer.Tests/UnitTest1.cs +++ b/NBXplorer.Tests/UnitTest1.cs @@ -30,7 +30,7 @@ namespace NBXplorer.Tests { - public class UnitTest1 + public partial class UnitTest1 { public UnitTest1(ITestOutputHelper helper) { diff --git a/NBXplorer/Backends/DBTrie/Repository.cs b/NBXplorer/Backends/DBTrie/Repository.cs index 6ff046310..648f1845e 100644 --- a/NBXplorer/Backends/DBTrie/Repository.cs +++ b/NBXplorer/Backends/DBTrie/Repository.cs @@ -1411,7 +1411,7 @@ public async Task GetMatches(IList t } foreach (var m in matches.Values) { - m.KnownKeyPathMappingUpdated(); + m.OwnedScriptsUpdated(); await AfterMatch(m, keyPathInformationsByTrackedTransaction[m]); } diff --git a/NBXplorer/Backends/Postgres/PostgresRepository.cs b/NBXplorer/Backends/Postgres/PostgresRepository.cs index e7d5c0295..920afab00 100644 --- a/NBXplorer/Backends/Postgres/PostgresRepository.cs +++ b/NBXplorer/Backends/Postgres/PostgresRepository.cs @@ -198,52 +198,58 @@ internal DescriptorKey GetDescriptorKey(DerivationStrategyBase strategy, Derivat } // metadata isn't really part of the key, but it's handy to have it here when we do INSERT INTO wallets. public record WalletKey(string wid, string metadata); - internal WalletKey GetWalletKey(DerivationStrategyBase strategy) + internal static WalletKey GetWalletKey(DerivationStrategyBase strategy, NBXplorerNetwork network) { - var hash = DBUtils.nbxv1_get_wallet_id(Network.CryptoCode, strategy.ToString()); + var hash = DBUtils.nbxv1_get_wallet_id(network.CryptoCode, strategy.ToString()); JObject m = new JObject(); m.Add(new JProperty("type", new JValue("NBXv1-Derivation"))); - m.Add(new JProperty("code", new JValue(Network.CryptoCode))); + m.Add(new JProperty("code", new JValue(network.CryptoCode))); m.Add(new JProperty("derivation", new JValue(strategy.ToString()))); return new WalletKey(hash, m.ToString(Formatting.None)); } - WalletKey GetWalletKey(GroupTrackedSource groupTrackedSource) + internal static WalletKey GetWalletKey(GroupTrackedSource groupTrackedSource) { var m = new JObject { new JProperty("type", new JValue("NBXv1-Group")) }; var res = new WalletKey($"G:" + groupTrackedSource.GroupId, m.ToString(Formatting.None)); return res; } - WalletKey GetWalletKey(IDestination destination) + internal static WalletKey GetWalletKey(IDestination destination, NBXplorerNetwork network) { - var address = destination.ScriptPubKey.GetDestinationAddress(Network.NBitcoinNetwork); - var hash = DBUtils.nbxv1_get_wallet_id(Network.CryptoCode, address.ToString()); + var address = destination.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork); + var hash = DBUtils.nbxv1_get_wallet_id(network.CryptoCode, address.ToString()); JObject m = new JObject(); m.Add(new JProperty("type", new JValue("NBXv1-Address"))); - m.Add(new JProperty("code", new JValue(Network.CryptoCode))); + m.Add(new JProperty("code", new JValue(network.CryptoCode))); m.Add(new JProperty("address", new JValue(address.ToString()))); return new WalletKey(hash, m.ToString(Formatting.None)); } - internal WalletKey GetWalletKey(TrackedSource source) + + internal WalletKey GetWalletKey(TrackedSource source) => GetWalletKey(source, Network); + internal static WalletKey GetWalletKey(TrackedSource source, NBXplorerNetwork network) { if (source is null) throw new ArgumentNullException(nameof(source)); return source switch { - DerivationSchemeTrackedSource derivation => GetWalletKey(derivation.DerivationStrategy), - AddressTrackedSource addr => GetWalletKey(addr.Address), + DerivationSchemeTrackedSource derivation => GetWalletKey(derivation.DerivationStrategy, network), + AddressTrackedSource addr => GetWalletKey(addr.Address, network), GroupTrackedSource group => GetWalletKey(group), _ => throw new NotSupportedException(source.GetType().ToString()) }; } internal TrackedSource TryGetTrackedSource(WalletKey walletKey) + { + return TryGetTrackedSource(walletKey, Network); + } + internal static TrackedSource TryGetTrackedSource(WalletKey walletKey, NBXplorerNetwork network) { var metadata = JObject.Parse(walletKey.metadata); if (metadata.TryGetValue("type", StringComparison.OrdinalIgnoreCase, out JToken typeJToken) && typeJToken.Value() is { } type) { if ((metadata.TryGetValue("code", StringComparison.OrdinalIgnoreCase, out JToken codeJToken) && - codeJToken.Value() is { } code) && !code.Equals(Network.CryptoCode, + codeJToken.Value() is { } code) && !code.Equals(network.CryptoCode, StringComparison.InvariantCultureIgnoreCase)) { return null; @@ -253,10 +259,10 @@ internal TrackedSource TryGetTrackedSource(WalletKey walletKey) { case "NBXv1-Derivation": var derivation = metadata["derivation"].Value(); - return new DerivationSchemeTrackedSource(Network.DerivationStrategyFactory.Parse(derivation)); + return new DerivationSchemeTrackedSource(network.DerivationStrategyFactory.Parse(derivation)); case "NBXv1-Address": var address = metadata["address"].Value(); - return new AddressTrackedSource(BitcoinAddress.Create(address, Network.NBitcoinNetwork)); + return new AddressTrackedSource(BitcoinAddress.Create(address, network.NBitcoinNetwork)); case "NBXv1-Group": return new GroupTrackedSource(walletKey.wid[2..]); // Skip "G:" } @@ -277,7 +283,7 @@ record GapNextIndex(long gap, long next_idx); internal async Task GenerateAddressesCore(DbConnection connection, DerivationStrategyBase strategy, DerivationFeature derivationFeature, GenerateAddressQuery query) { var descriptorKey = GetDescriptorKey(strategy, derivationFeature); - var walletKey = GetWalletKey(strategy); + var walletKey = GetWalletKey(strategy, Network); var gapNextIndex = await GetGapAndNextIdx(connection, descriptorKey); long toGenerate = ToGenerateCount(query, gapNextIndex?.gap); if (gapNextIndex is not null && toGenerate == 0) @@ -399,10 +405,11 @@ private static async Task GetGapAndNextIdx(DbConnection connection "WHERE code=@code AND descriptor=@descriptor", descriptorKey); } + internal const string InsertScriptsScript = "INSERT INTO scripts (code, script, addr, used) SELECT @code code, script, addr, used FROM unnest(@records) ON CONFLICT DO NOTHING;"; private async Task InsertDescriptorsScripts(DbConnection connection, IList batch) { await connection.ExecuteAsync( - "INSERT INTO scripts (code, script, addr, used) SELECT @code code, script, addr, used FROM unnest(@records) ON CONFLICT DO NOTHING;" + + InsertScriptsScript + "INSERT INTO descriptors_scripts (code, descriptor, idx, script, metadata, used) SELECT @code code, descriptor, idx, script, metadata, used FROM unnest(@records) ON CONFLICT DO NOTHING;" , new { @@ -489,7 +496,7 @@ async Task> GetKeyInformations( var rows = await connection.QueryAsync($@" SELECT ts.code, ts.script, ts.addr, ts.derivation, ts.keypath, ts.redeem{additionalColumn}, ts.wallet_id, - w.metadata->>'type' AS wallet_metadata + w.metadata AS wallet_metadata FROM unnest(@records) AS r (script), LATERAL ( SELECT code, script, wallet_id, addr, descriptor_metadata->>'derivation' derivation, @@ -766,7 +773,7 @@ SaveTransactionRecord CreateTransactionRecord(Transaction tx) => SaveTransaction } foreach (var m in matches.Values) { - m.KnownKeyPathMappingUpdated(); + m.OwnedScriptsUpdated(); if (elementContext is not null) await elementContext.Unblind(rpc, m); } @@ -834,9 +841,9 @@ public async Task GetTransactions(TrackedSource trackedSou var tip = await connection.GetTip(); var txIdCond = txId is null ? string.Empty : " AND tx_id=@tx_id"; var utxos = await - connection.Connection.QueryAsync<(string tx_id, long idx, string blk_id, long? blk_height, int? blk_idx, bool is_out, string spent_tx_id, long spent_idx, string script, long value, string asset_id, bool immature, string keypath, DateTime seen_at)>( - "SELECT tx_id, idx, blk_id, blk_height, blk_idx, is_out, spent_tx_id, spent_idx, script, value, asset_id, immature, keypath, seen_at " + - "FROM nbxv1_tracked_txs " + + connection.Connection.QueryAsync<(string tx_id, long idx, string blk_id, long? blk_height, int? blk_idx, bool is_out, string spent_tx_id, long spent_idx, string script, string addr, long value, string asset_id, bool immature, string keypath, DateTime seen_at)>( + "SELECT 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}", new { code = Network.CryptoCode, walletId = GetWalletKey(trackedSource).wid, tx_id = txId?.ToString() }); utxos.TryGetNonEnumeratedCount(out int c); var trackedById = new Dictionary(c); @@ -847,6 +854,7 @@ public async Task GetTransactions(TrackedSource trackedSou { var txout = Network.NBitcoinNetwork.Consensus.ConsensusFactory.CreateTxOut(); txout.ScriptPubKey = Script.FromHex(utxo.script); + tracked.KnownAddresses.TryAdd(txout.ScriptPubKey, BitcoinAddress.Create(utxo.addr, this.Network.NBitcoinNetwork)); txout.Value = Money.Satoshis(utxo.value); var coin = new Coin(new OutPoint(tracked.Key.TxId, (uint)utxo.idx), txout); if (Network.IsElement) @@ -857,11 +865,13 @@ public async Task GetTransactions(TrackedSource trackedSou { tracked.ReceivedCoins.Add(coin); } + // TODO: IsCoinBase is actually not used anywhere. tracked.IsCoinBase = utxo.immature; tracked.Immature = utxo.immature; if (utxo.keypath is string) tracked.KnownKeyPathMapping.TryAdd(txout.ScriptPubKey, KeyPath.Parse(utxo.keypath)); + tracked.OwnedScripts.Add(txout.ScriptPubKey); } else { @@ -889,7 +899,7 @@ public async Task GetTransactions(TrackedSource trackedSou return trackedById.Values.Select(c => { - c.KnownKeyPathMappingUpdated(); + c.OwnedScriptsUpdated(); return c; }).ToArray(); } @@ -1241,7 +1251,7 @@ private async Task GetIndexProgressCore(DbConnection connection) public async Task Track(IDestination address) { await using var conn = await GetConnection(); - var walletKey = GetWalletKey(address); + var walletKey = GetWalletKey(address, Network); await conn.Connection.ExecuteAsync( WalletInsertQuery + "INSERT INTO scripts VALUES (@code, @script, @addr) ON CONFLICT DO NOTHING;" + @@ -1331,12 +1341,11 @@ public async Task SaveBlocks(IList slimBlocks) public async Task EnsureWalletCreated(DerivationStrategyBase strategy) { - await EnsureWalletCreated(GetWalletKey(strategy)); + await EnsureWalletCreated(GetWalletKey(strategy, Network)); } - public async Task EnsureWalletCreated(TrackedSource trackedSource, params TrackedSource[] parentTrackedSource) + public async Task EnsureWalletCreated(TrackedSource trackedSource) { - parentTrackedSource = parentTrackedSource.Where(source => source is not null).ToArray(); await EnsureWalletCreated(GetWalletKey(trackedSource)); } @@ -1347,7 +1356,7 @@ public async Task EnsureWalletCreated(WalletKey walletKey) await connection.ExecuteAsync(WalletInsertQuery, walletKey); } - private readonly string WalletInsertQuery = "INSERT INTO wallets (wallet_id, metadata) VALUES (@wid, @metadata::JSONB) ON CONFLICT DO NOTHING;"; + internal static readonly string WalletInsertQuery = "INSERT INTO wallets (wallet_id, metadata) VALUES (@wid, @metadata::JSONB) ON CONFLICT DO NOTHING;"; } public class LegacyDescriptorMetadata diff --git a/NBXplorer/Controllers/CommonRoutes.cs b/NBXplorer/Controllers/CommonRoutes.cs index 2d93020f7..dd8f0a707 100644 --- a/NBXplorer/Controllers/CommonRoutes.cs +++ b/NBXplorer/Controllers/CommonRoutes.cs @@ -6,7 +6,7 @@ public static class CommonRoutes public const string BaseDerivationEndpoint = $"{BaseCryptoEndpoint}/derivations"; public const string DerivationEndpoint = $"{BaseCryptoEndpoint}/derivations/{{derivationScheme}}"; public const string AddressEndpoint = $"{BaseCryptoEndpoint}/addresses/{{address}}"; - public const string GroupEndpoint = $"{BaseCryptoEndpoint}/groups/{{groupId}}"; - public const string TrackedSourceEndpoint = $"{BaseCryptoEndpoint}/tracked-sources/{{trackedSource}}"; + public const string BaseGroupEndpoint = $"groups"; + public const string GroupEndpoint = $"{BaseGroupEndpoint}/{{groupId}}"; public const string TransactionsPath = "transactions/{txId?}"; } \ No newline at end of file diff --git a/NBXplorer/Controllers/GroupsController.cs b/NBXplorer/Controllers/GroupsController.cs new file mode 100644 index 000000000..6dff977f6 --- /dev/null +++ b/NBXplorer/Controllers/GroupsController.cs @@ -0,0 +1,177 @@ +using Microsoft.AspNetCore.Mvc; +using NBitcoin; +using NBXplorer.DerivationStrategy; +using NBXplorer.ModelBinders; +using NBXplorer.Models; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NBitcoin.RPC; +using NBXplorer.Analytics; +using NBXplorer.Backends; +using NBXplorer.Backends.Postgres; +using Dapper; +using Microsoft.AspNetCore.Http; +using static NBXplorer.Backends.Postgres.PostgresRepository; +using Microsoft.AspNetCore.Authorization; +using RabbitMQ.Client; +using Npgsql; +using NBitcoin.Crypto; +using static NBitcoin.Scripting.OutputDescriptor; +using NBitcoin.Protocol; + +namespace NBXplorer.Controllers +{ + [Route($"v1")] + [PostgresImplementationActionConstraint(true)] + [Authorize] + public class GroupsController : Controller + { + public GroupsController( + DbConnectionFactory connectionFactory, + NBXplorerNetworkProvider networkProvider) + { + ConnectionFactory = connectionFactory; + NetworkProvider = networkProvider; + } + public DbConnectionFactory ConnectionFactory { get; } + public NBXplorerNetworkProvider NetworkProvider { get; } + + [HttpPost(CommonRoutes.BaseGroupEndpoint)] + public async Task CreateGroup() + { + var group = GroupTrackedSource.Generate(); + await using var conn = await ConnectionFactory.CreateConnection(); + await conn.ExecuteAsync(PostgresRepository.WalletInsertQuery, PostgresRepository.GetWalletKey(group)); + return base.Ok(ToGroupInfo(group)); + } + + [HttpGet(CommonRoutes.GroupEndpoint)] + public async Task GetGroup(string groupId) + { + var group = new GroupTrackedSource(groupId); + var w = PostgresRepository.GetWalletKey(new GroupTrackedSource(groupId)); + await using var conn = await ConnectionFactory.CreateConnection(); + var children = (await conn.QueryAsync( + "SELECT wc.wallet_id wid, wc.metadata FROM wallets w " + + "LEFT JOIN wallets_wallets ww ON ww.parent_id=w.wallet_id " + + "LEFT JOIN wallets wc ON wc.wallet_id=ww.wallet_id " + + "WHERE w.wallet_id=@wid", new { w.wid })).ToArray(); + if (children.Length == 0) + throw GroupNotFound(); + var groupInfo = ToGroupInfo(group); + if (!(children.Length is 1 && children[0].wid is null)) + groupInfo.Children = ToGroupChildren(children); + return Ok(groupInfo); + } + + private static NBXplorerException GroupNotFound() + { + return new NBXplorerException(new NBXplorerError(404, "group-not-found", "The group doesn't exist")); + } + + private GroupChild[] ToGroupChildren(WalletKey[] children) => children.Select(x => ToGroupChild(x)).Where(x => x is not null).ToArray(); + + private GroupChild ToGroupChild(WalletKey walletKey) + { + if (walletKey is null) + return null; + var cryptoCode = JObject.Parse(walletKey.metadata)["code"]?.Value(); + NBXplorerNetwork net = null; + if (cryptoCode != null) + { + net = NetworkProvider.GetFromCryptoCode(cryptoCode); + if (net is null) + return null; + } + var trackedSource = PostgresRepository.TryGetTrackedSource(walletKey, net); + return new GroupChild() { CryptoCode = net?.CryptoCode, TrackedSource = trackedSource.ToString() }; + } + + [HttpPost($"{CommonRoutes.GroupEndpoint}/children")] + [HttpDelete($"{CommonRoutes.GroupEndpoint}/children")] + public async Task AddDeleteGroupChildren(string groupId, [FromBody] GroupChild[] children) + { + var w = PostgresRepository.GetWalletKey(new GroupTrackedSource(groupId)); + await using (var conn = await ConnectionFactory.CreateConnection()) + { + var rows = children + .Select(c => GetWid(c)) + .Where(c => c is not null) + .Select(c => new { child = c, w.wid }).ToArray(); + if (HttpContext.Request.Method == "POST") + try + { + await conn.ExecuteAsync("INSERT INTO wallets_wallets VALUES (@child, @wid) ON CONFLICT (wallet_id, parent_id) DO NOTHING", rows); + } + catch (NpgsqlException ex) when (ex.SqlState == PostgresErrorCodes.RaiseException) + { + throw new NBXplorerException(new NBXplorerError(409, "cycle-detected", "A cycle has been detected")); + } + catch (NpgsqlException ex) when (ex.SqlState == PostgresErrorCodes.ForeignKeyViolation) + { + throw GroupNotFound(); + } + if (HttpContext.Request.Method == "DELETE") + await conn.ExecuteAsync("DELETE FROM wallets_wallets WHERE wallet_id=@child AND parent_id=@wid;", rows); + } + return await GetGroup(groupId); + } + + [HttpPost($"{CommonRoutes.BaseCryptoEndpoint}/{CommonRoutes.GroupEndpoint}/addresses")] + public async Task AddGroupAddress(TrackedSourceContext trackedSourceContext, [FromBody] string[] addresses) + { + var group = (GroupTrackedSource)trackedSourceContext.TrackedSource; + IList rows; + try + { + rows = addresses + .Where(a => a is not null) + .Select(a => BitcoinAddress.Create(a, trackedSourceContext.Network.NBitcoinNetwork)) + .Select(a => new DescriptorScriptInsert("", 0, a.ScriptPubKey.ToHex(), "{}", a.ToString(), false)) + .ToList(); + } + catch (FormatException) + { + throw new NBXplorerException(new NBXplorerError(400, "invalid-address", + $"An address cannot be parsed")); + } + await using (var conn = await ConnectionFactory.CreateConnection()) + { + await conn.ExecuteAsync(PostgresRepository.InsertScriptsScript + + "INSERT INTO wallets_scripts (code, script, wallet_id) SELECT @code code, script, @wid FROM unnest(@records) ON CONFLICT DO NOTHING;", + new + { + code = trackedSourceContext.Network.CryptoCode, + wid = PostgresRepository.GetWalletKey(group).wid, + records = rows + }); + } + return Ok(); + } + + private string GetWid(GroupChild c) + { + if (c?.TrackedSource is null) + return null; + var net = c.CryptoCode is null ? null : NetworkProvider.GetFromCryptoCode(c.CryptoCode); + if (c.TrackedSource.StartsWith("ADDRESS:") || c.TrackedSource.StartsWith("DERIVATIONSCHEME:") && c.CryptoCode is null) + throw new NBXplorerException(new NBXplorerError(400, "invalid-group-child", "ADDRESS: and DERIVATIONSCHEME: tracked sources must also include a cryptoCode parameter")); + if (!TrackedSource.TryParse(c.TrackedSource, out var ts, net)) + throw new NBXplorerException(new NBXplorerError(400, "invalid-group-child", "Invalid tracked source format")); + return PostgresRepository.GetWalletKey(ts, net)?.wid; + } + + private static GroupInformation ToGroupInfo(GroupTrackedSource group) + { + return new GroupInformation() + { + TrackedSource = group.ToString(), + GroupId = group.GroupId, + Children = Array.Empty() + }; + } + } +} diff --git a/NBXplorer/Controllers/MainController.cs b/NBXplorer/Controllers/MainController.cs index b947d0a97..2f0e42a7b 100644 --- a/NBXplorer/Controllers/MainController.cs +++ b/NBXplorer/Controllers/MainController.cs @@ -25,7 +25,6 @@ using NBXplorer.Backends; using NBitcoin.Scripting; using System.Globalization; -using NBXplorer.Backends.Postgres; namespace NBXplorer.Controllers { @@ -505,8 +504,6 @@ private bool HasTxIndex(string cryptoCode) [HttpPost] [Route($"{CommonRoutes.DerivationEndpoint}")] [Route($"{CommonRoutes.AddressEndpoint}")] - [Route($"{CommonRoutes.GroupEndpoint}")] - [Route($"{CommonRoutes.TrackedSourceEndpoint}")] public async Task TrackWallet( TrackedSourceContext trackedSourceContext, [FromBody] JObject rawRequest = null) @@ -560,8 +557,7 @@ private GenerateAddressQuery GenerateAddressQuery(TrackWalletRequest request, De [HttpGet] [Route($"{CommonRoutes.DerivationEndpoint}/{CommonRoutes.TransactionsPath}")] [Route($"{CommonRoutes.AddressEndpoint}/{CommonRoutes.TransactionsPath}")] - [Route($"{CommonRoutes.GroupEndpoint}/{CommonRoutes.TransactionsPath}")] - [Route($"{CommonRoutes.TrackedSourceEndpoint}/{CommonRoutes.TransactionsPath}")] + [Route($"{CommonRoutes.BaseCryptoEndpoint}/{CommonRoutes.GroupEndpoint}/{CommonRoutes.TransactionsPath}")] public async Task GetTransactions( TrackedSourceContext trackedSourceContext, [ModelBinder(BinderType = typeof(UInt256ModelBinding))] @@ -760,15 +756,18 @@ public async Task Rescan(TrackedSourceContext trackedSourceContex } } - [HttpPost] - [Route($"{CommonRoutes.DerivationEndpoint}/metadata/{{key}}")] + [HttpPost($"{CommonRoutes.DerivationEndpoint}/metadata/{{key}}")] + [HttpPost($"{CommonRoutes.AddressEndpoint}/metadata/{{key}}")] + [HttpPost($"{CommonRoutes.GroupEndpoint}/metadata/{{key}}")] public async Task SetMetadata(TrackedSourceContext trackedSourceContext, string key, [FromBody] JToken value = null) { await trackedSourceContext.Repository.SaveMetadata(trackedSourceContext.TrackedSource, key, value); return Ok(); } - [HttpGet] - [Route($"{CommonRoutes.DerivationEndpoint}/metadata/{{key}}")] + + [HttpGet($"{CommonRoutes.DerivationEndpoint}/metadata/{{key}}")] + [HttpGet($"{CommonRoutes.AddressEndpoint}/metadata/{{key}}")] + [HttpGet($"{CommonRoutes.GroupEndpoint}/metadata/{{key}}")] public async Task GetMetadata(TrackedSourceContext trackedSourceContext, string key) { var result = await trackedSourceContext.Repository.GetMetadata(trackedSourceContext.TrackedSource, key); @@ -1101,6 +1100,7 @@ await TrackWallet(new TrackedSourceContext() }); return Json(new GenerateWalletResponse() { + TrackedSource = new DerivationSchemeTrackedSource(derivation).ToString(), MasterHDKey = masterKey, AccountHDKey = accountKey, AccountKeyPath = accountKeyPath, diff --git a/NBXplorer/Controllers/PostgresMainController.cs b/NBXplorer/Controllers/PostgresMainController.cs index 70f48c0f3..ae7ef52bc 100644 --- a/NBXplorer/Controllers/PostgresMainController.cs +++ b/NBXplorer/Controllers/PostgresMainController.cs @@ -22,8 +22,7 @@ namespace NBXplorer.Controllers [PostgresImplementationActionConstraint(true)] [Route($"v1/{CommonRoutes.DerivationEndpoint}")] [Route($"v1/{CommonRoutes.AddressEndpoint}")] - [Route($"v1/{CommonRoutes.GroupEndpoint}")] - [Route($"v1/{CommonRoutes.TrackedSourceEndpoint}")] + [Route($"v1/{CommonRoutes.BaseCryptoEndpoint}/{CommonRoutes.GroupEndpoint}")] [Authorize] public class PostgresMainController : Controller, IUTXOService { diff --git a/NBXplorer/DBScripts/022.WalletsWalletsParentIdIndex.sql b/NBXplorer/DBScripts/022.WalletsWalletsParentIdIndex.sql new file mode 100644 index 000000000..8acbfc800 --- /dev/null +++ b/NBXplorer/DBScripts/022.WalletsWalletsParentIdIndex.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS wallets_wallets_parent_id ON wallets_wallets (parent_id); \ No newline at end of file diff --git a/NBXplorer/HostedServices/DBTrieToPostgresMigratorHostedService.cs b/NBXplorer/HostedServices/DBTrieToPostgresMigratorHostedService.cs index f1a9851a6..52ccc4812 100644 --- a/NBXplorer/HostedServices/DBTrieToPostgresMigratorHostedService.cs +++ b/NBXplorer/HostedServices/DBTrieToPostgresMigratorHostedService.cs @@ -351,7 +351,7 @@ await conn.ExecuteAsync( KeyPathTemplate = keyTemplate, Type = LegacyDescriptorMetadata.TypeName }), - postgresRepo.GetWalletKey(ts.DerivationStrategy).wid)); + PostgresRepository.GetWalletKey(ts.DerivationStrategy, network).wid)); } var wk = postgresRepo.GetWalletKey(keyInfo.TrackedSource); if (processedWalletKeys.Add(wk.wid)) diff --git a/NBXplorer/NBXplorer.csproj b/NBXplorer/NBXplorer.csproj index 56bc67daf..8a7e9c526 100644 --- a/NBXplorer/NBXplorer.csproj +++ b/NBXplorer/NBXplorer.csproj @@ -28,6 +28,7 @@ + diff --git a/NBXplorer/TrackedTransaction.cs b/NBXplorer/TrackedTransaction.cs index 4a3a2036c..86b54e50c 100644 --- a/NBXplorer/TrackedTransaction.cs +++ b/NBXplorer/TrackedTransaction.cs @@ -69,19 +69,23 @@ public TrackedTransaction(TrackedTransactionKey key, TrackedSource trackedSource Transaction = transaction; transaction.PrecomputeHash(false, true); KnownKeyPathMapping = knownScriptMapping; - - KnownKeyPathMappingUpdated(); + if ((TrackedSource as IDestination)?.ScriptPubKey is Script s) + OwnedScripts.Add(s); + foreach (var ss in knownScriptMapping.Keys) + OwnedScripts.Add(ss); + OwnedScriptsUpdated(); } - internal void KnownKeyPathMappingUpdated() + internal void OwnedScriptsUpdated() { if (Transaction == null) return; - var scriptPubKey = (TrackedSource as IDestination)?.ScriptPubKey; + ReceivedCoins.Clear(); + SpentOutpoints.Clear(); for (int i = 0; i < Transaction.Outputs.Count; i++) { var output = Transaction.Outputs[i]; - if (KnownKeyPathMapping.ContainsKey(output.ScriptPubKey) || scriptPubKey == output.ScriptPubKey) + if (OwnedScripts.Contains(output.ScriptPubKey)) ReceivedCoins.Add(new Coin(new OutPoint(Key.TxId, i), output)); } @@ -89,8 +93,9 @@ internal void KnownKeyPathMappingUpdated() SpentOutpoints.AddInputs(Transaction); } + public HashSet