Skip to content

Commit

Permalink
Add Group API
Browse files Browse the repository at this point in the history
  • Loading branch information
NicolasDorier committed Jan 10, 2024
1 parent 5d4d028 commit decdf06
Show file tree
Hide file tree
Showing 17 changed files with 669 additions and 224 deletions.
22 changes: 22 additions & 0 deletions NBXplorer.Client/ExplorerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,28 @@ public GenerateWalletResponse GenerateWallet(GenerateWalletRequest request = nul
return GenerateWalletAsync(request, cancellationToken).GetAwaiter().GetResult();
}

public Task<GroupInformation> CreateGroupAsync(CancellationToken cancellationToken = default)
{
return SendAsync<GroupInformation>(HttpMethod.Post, null, $"v1/groups", cancellationToken);
}
public Task<GroupInformation> GetGroupAsync(string groupId, CancellationToken cancellationToken = default)
{
return SendAsync<GroupInformation>(HttpMethod.Get, null, $"v1/groups/{groupId}", cancellationToken);
}
public Task<GroupInformation> AddGroupChildrenAsync(string groupId, GroupChild[] children, CancellationToken cancellationToken = default)
{
return SendAsync<GroupInformation>(HttpMethod.Post, children, $"v1/groups/{groupId}/children", cancellationToken);
}
public Task<GroupInformation> RemoveGroupChildrenAsync(string groupId, GroupChild[] children, CancellationToken cancellationToken = default)
{
return SendAsync<GroupInformation>(HttpMethod.Delete, children, $"v1/groups/{groupId}/children", cancellationToken);
}

public Task AddGroupAddressAsync(string cryptoCode, string groupId, string[] addresses, CancellationToken cancellationToken = default)
{
return SendAsync<GroupInformation>(HttpMethod.Post, addresses, $"v1/cryptos/{cryptoCode}/groups/{groupId}/addresses", cancellationToken);
}

private static readonly HttpClient SharedClient = new HttpClient();
internal HttpClient Client = SharedClient;

Expand Down
1 change: 1 addition & 0 deletions NBXplorer.Client/Models/GenerateWalletResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
22 changes: 22 additions & 0 deletions NBXplorer.Client/Models/GroupInformation.cs
Original file line number Diff line number Diff line change
@@ -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 };
}
}
18 changes: 15 additions & 3 deletions NBXplorer.Client/Models/TrackedSource.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using NBitcoin;
using NBitcoin.DataEncoders;
using NBXplorer.DerivationStrategy;
using System;
using System.Security.Cryptography;

namespace NBXplorer.Models
{
Expand All @@ -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;
Expand Down Expand Up @@ -107,6 +111,14 @@ public class GroupTrackedSource : TrackedSource
{
public string GroupId { get; }

public static GroupTrackedSource Generate()
{
Span<byte> 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;
Expand All @@ -133,7 +145,7 @@ public override string ToString()
}
public override string ToPrettyString()
{
return GroupId;
return "G:" + GroupId;
}

public static GroupTrackedSource Parse(string trackedSource)
Expand Down
136 changes: 136 additions & 0 deletions NBXplorer.Tests/UnitTest1.Groups.cs
Original file line number Diff line number Diff line change
@@ -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<GroupChild>()));

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<string[]> GetGroupAddressesAsync(ServerTester tester, string code, string groupId)
{
await using var conn = await tester.GetService<DbConnectionFactory>().CreateConnection();
return (await conn.QueryAsync<string>("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<NBXplorerException> AssertNBXplorerException(int httpCode, Task<GroupInformation> task)
{
var ex = await Assert.ThrowsAsync<NBXplorerException>(() => task);
Assert.Equal(httpCode, ex.Error.HttpCode);
return ex;
}
}
}
2 changes: 1 addition & 1 deletion NBXplorer.Tests/UnitTest1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

namespace NBXplorer.Tests
{
public class UnitTest1
public partial class UnitTest1
{
public UnitTest1(ITestOutputHelper helper)
{
Expand Down
2 changes: 1 addition & 1 deletion NBXplorer/Backends/DBTrie/Repository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1411,7 +1411,7 @@ public async Task<TrackedTransaction[]> GetMatches(IList<NBitcoin.Transaction> t
}
foreach (var m in matches.Values)
{
m.KnownKeyPathMappingUpdated();
m.OwnedScriptsUpdated();
await AfterMatch(m, keyPathInformationsByTrackedTransaction[m]);
}

Expand Down
Loading

0 comments on commit decdf06

Please sign in to comment.