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 9, 2024
1 parent 5d4d028 commit 6d4c51f
Show file tree
Hide file tree
Showing 14 changed files with 396 additions and 35 deletions.
26 changes: 26 additions & 0 deletions NBXplorer.Client/ExplorerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,32 @@ 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);
}
public Task<string[]> GetGroupAddressesAsync(string cryptoCode, string groupId, CancellationToken cancellationToken = default)
{
return SendAsync<string[]>(HttpMethod.Get, null, $"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
108 changes: 108 additions & 0 deletions NBXplorer.Tests/UnitTest1.Groups.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using Microsoft.AspNetCore.Mvc.ViewFeatures.Infrastructure;
using NBitcoin;
using NBXplorer.Models;
using System;
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 tester.Client.GetGroupAddressesAsync("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 tester.Client.GetGroupAddressesAsync("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 tester.Client.GetGroupAddressesAsync("BTC", g1.GroupId);
Assert.Empty(g1Addrs);
}

[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);
}

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
50 changes: 28 additions & 22 deletions NBXplorer/Backends/Postgres/PostgresRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>() is { } type)
{
if ((metadata.TryGetValue("code", StringComparison.OrdinalIgnoreCase, out JToken codeJToken) &&
codeJToken.Value<string>() is { } code) && !code.Equals(Network.CryptoCode,
codeJToken.Value<string>() is { } code) && !code.Equals(network.CryptoCode,
StringComparison.InvariantCultureIgnoreCase))
{
return null;
Expand All @@ -253,10 +259,10 @@ internal TrackedSource TryGetTrackedSource(WalletKey walletKey)
{
case "NBXv1-Derivation":
var derivation = metadata["derivation"].Value<string>();
return new DerivationSchemeTrackedSource(Network.DerivationStrategyFactory.Parse(derivation));
return new DerivationSchemeTrackedSource(network.DerivationStrategyFactory.Parse(derivation));
case "NBXv1-Address":
var address = metadata["address"].Value<string>();
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:"
}
Expand All @@ -277,7 +283,7 @@ record GapNextIndex(long gap, long next_idx);
internal async Task<int> 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)
Expand Down Expand Up @@ -399,10 +405,11 @@ private static async Task<GapNextIndex> 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<DescriptorScriptInsert> 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
{
Expand Down Expand Up @@ -489,7 +496,7 @@ async Task<MultiValueDictionary<Script, KeyPathInformation>> 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,
Expand Down Expand Up @@ -1241,7 +1248,7 @@ private async Task<BlockLocator> 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;" +
Expand Down Expand Up @@ -1331,12 +1338,11 @@ public async Task SaveBlocks(IList<SlimChainedBlock> 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));
}

Expand All @@ -1347,7 +1353,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
Expand Down
4 changes: 2 additions & 2 deletions NBXplorer/Controllers/CommonRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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?}";
}
Loading

0 comments on commit 6d4c51f

Please sign in to comment.