Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions DotNut.Tests/Integration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
using DotNut.Abstractions.Websockets;
using DotNut.Api;
using DotNut.ApiModels;
using DotNut.NBitcoin.BIP39;
using DotNut.NUT13;

namespace DotNut.Tests;

Expand Down Expand Up @@ -433,6 +435,42 @@ await wallet.Swap().FromInputs(proofs).ProcessAsync()
Assert.NotEmpty(swappedProofs);
}

[Fact]
public async Task SwapDeterministicP2Pk()
{
// Shares the counter with the other deterministic tests, otherwise restarting it from
// zero would re-derive secrets they already minted with this seed.
var wallet = Wallet.Create().WithMint(MintUrl).WithMnemonic(seed).WithCounter(counter);

var derivationCounter = wallet.GetDerivationCounter()!;
var before = await derivationCounter.GetCounter(DerivationPurpose.P2Pk);

var mintHandler = await wallet
.CreateMintQuote()
.WithAmount(1337)
.WithDeterministicP2PkLock()
.ProcessAsyncBolt11();

await PayInvoice();
var proofs = await mintHandler.Mint();

// The lock key is the one at the counter we started from, and it moved past it.
var derived = new Mnemonic(seed).DeriveP2PkPrivkey(before);
Assert.Equal(before + 1, await derivationCounter.GetCounter(DerivationPurpose.P2Pk));

await Assert.ThrowsAsync<CashuProtocolException>(async () =>
await wallet.Swap().FromInputs(proofs).ProcessAsync()
);

var swappedProofs = await wallet
.Swap()
.FromInputs(proofs)
.WithPrivkeys([derived])
.ProcessAsync();

Assert.NotEmpty(swappedProofs);
}

[Fact]
public async Task MintMeltP2PkMultisig()
{
Expand Down
37 changes: 37 additions & 0 deletions DotNut.Tests/Unit/Nut13Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,41 @@ public void Nut13HMACTests()
Convert.ToHexString(mnemonic.DeriveBlindingFactor(keysetId, 4)).ToLowerInvariant()
);
}

[Fact]
public void Nut13P2PkTests()
{
// Test vectors from tests/13-tests.md, "P2PK Derivation" (m/129373'/10'/0'/0'/{counter})
var mnemonic = new Mnemonic(
"half depart obvious quality work element tank gorilla view sugar picture humble"
);
string[] keys =
[
"021693d45f4fdf610ae641fedb0944fb460fbb8264f21c19d2626c3da755fcbbcb",
"0395461ab678058c0ed6aa39f38dda490eaa163e9ad27070b23ec3d06b41e07535",
"02a05e4e593a633e9b4405f01c9632c8afde24cb613017a1aee56fd76291ad26d1",
"033addea25c3873b93d67d536c61c9d9c993f6efd8b9dfa657951b66b5001e51dd",
"03c964bdf42fc82b6c574615746eeca37527a24f1fdfc1b34a732c53843b5744a5",
];
for (var i = 0u; i < (uint)keys.Length; i++)
{
var privkey = mnemonic.DeriveP2PkPrivkey(i);
Assert.Equal(new PubKey(keys[i]), (PubKey)privkey.Key.CreatePubKey());
}
}

[Fact]
public void Nut13P2PkRejectsHardenedCounter()
{
var mnemonic = new Mnemonic(
"half depart obvious quality work element tank gorilla view sugar picture humble"
);

// The last child index is non-hardened, so 2^31 - 1 is the largest valid counter.
// Without the guard 2^31 silently derives the hardened index 0 instead.
Assert.NotNull(mnemonic.DeriveP2PkPrivkey(int.MaxValue));
Assert.Throws<ArgumentOutOfRangeException>(() =>
mnemonic.DeriveP2PkPrivkey((uint)int.MaxValue + 1)
);
}
}
57 changes: 57 additions & 0 deletions DotNut.Tests/Unit/UnitTests2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,63 @@ public async Task InMemoryCounter()
Assert.Equal((uint)1337, ctrNum);
}

[Fact]
public async Task InMemoryCounter_DerivationPurpose()
{
var ctr = new InMemoryCounter();

Assert.Equal((uint)0, await ctr.GetCounter(DerivationPurpose.P2Pk));

var (old, @new) = await ctr.FetchAndIncrement(DerivationPurpose.P2Pk, 3);
Assert.Equal((uint)0, old);
Assert.Equal((uint)3, @new);
Assert.Equal((uint)3, await ctr.GetCounter(DerivationPurpose.P2Pk));

await ctr.SetCounter(DerivationPurpose.P2Pk, 1337);
Assert.Equal((uint)1337, await ctr.GetCounter(DerivationPurpose.P2Pk));

// Each purpose is its own counter, and neither touches the keyset counters.
Assert.Equal((uint)0, await ctr.GetCounter(DerivationPurpose.MintQuoteLock));
Assert.Empty(await ctr.Export());
}

[Fact]
public void Wallet_ExposesDerivationCounterOnlyWhenSupported()
{
var supported = Wallet.Create().WithCounter(new InMemoryCounter());
Assert.NotNull(supported.GetDerivationCounter());

// An ICounter that predates IDerivationCounter still works, it just has no
// keyset-independent counters.
var unsupported = Wallet.Create().WithCounter(new KeysetOnlyCounter());
Assert.NotNull(unsupported.GetCounter());
Assert.Null(unsupported.GetDerivationCounter());
}

private class KeysetOnlyCounter : ICounter
{
public Task<uint> GetCounterForId(KeysetId keysetId, CancellationToken ct = default) =>
Task.FromResult(0u);

public Task<uint> IncrementCounter(
KeysetId keysetId,
uint bumpBy = 1,
CancellationToken ct = default
) => Task.FromResult(bumpBy);

public Task<(uint oldValue, uint newValue)> FetchAndIncrement(
KeysetId keysetId,
uint bumpBy = 1,
CancellationToken ct = default
) => Task.FromResult((0u, bumpBy));

public Task SetCounter(KeysetId keysetId, uint counter, CancellationToken ct = default) =>
Task.CompletedTask;

public Task<IReadOnlyDictionary<KeysetId, uint>> Export() =>
Task.FromResult<IReadOnlyDictionary<KeysetId, uint>>(new Dictionary<KeysetId, uint>());
}

[Fact]
public void SplitAmountsForPayment_ExactAmount_ReturnsCorrectSplit()
{
Expand Down
18 changes: 18 additions & 0 deletions DotNut/Abstractions/DerivationPurpose.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace DotNut.Abstractions;

/// <summary>
/// A wallet-level derivation counter that is not tied to a keyset. Each purpose has its own
/// derivation path and its own counter, independent from the per-keyset NUT-13 counters.
/// </summary>
public enum DerivationPurpose
{
/// <summary>
/// NUT-13 P2PK keys to lock proofs to: <c>m/129373'/10'/0'/0'/{counter}</c>.
/// </summary>
P2Pk,

/// <summary>
/// NUT-20 mint quote locking keys: <c>m/129373'/20'/0'/0'/{counter}</c>.
/// </summary>
MintQuoteLock,
}
38 changes: 37 additions & 1 deletion DotNut/Abstractions/InMemoryCounter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

namespace DotNut.Abstractions;

public class InMemoryCounter : ICounter
public class InMemoryCounter : ICounter, IDerivationCounter
{
private readonly ConcurrentDictionary<KeysetId, uint> _counter;
private readonly ConcurrentDictionary<DerivationPurpose, uint> _purposeCounter = new();

public InMemoryCounter(IDictionary<KeysetId, uint> counter)
{
Expand Down Expand Up @@ -61,4 +62,39 @@ public async Task<IReadOnlyDictionary<KeysetId, uint>> Export()
{
return new Dictionary<KeysetId, uint>(_counter);
}

public Task<uint> GetCounter(DerivationPurpose purpose, CancellationToken ct = default)
{
return Task.FromResult(_purposeCounter.GetOrAdd(purpose, 0u));
}

public Task<(uint oldValue, uint newValue)> FetchAndIncrement(
DerivationPurpose purpose,
uint bumpBy = 1,
CancellationToken ct = default
)
{
uint oldValue = 0;
uint newValue = _purposeCounter.AddOrUpdate(
purpose,
bumpBy,
(_, current) =>
{
oldValue = current;
return current + bumpBy;
}
);

return Task.FromResult((oldValue, newValue));
}

public Task SetCounter(
DerivationPurpose purpose,
uint counter,
CancellationToken ct = default
)
{
_purposeCounter[purpose] = counter;
return Task.CompletedTask;
}
}
29 changes: 29 additions & 0 deletions DotNut/Abstractions/Interfaces/IDerivationCounter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace DotNut.Abstractions;

/// <summary>
/// Counters for derivations that are not tied to a keyset, such as NUT-13 P2PK keys.
/// Kept separate from <see cref="ICounter"/> so that existing implementations keep compiling;
/// an <see cref="ICounter"/> that does not also implement this only lacks those derivations.
/// </summary>
public interface IDerivationCounter
{
/// <summary>
/// Gets the counter for a derivation purpose. Like the keyset counters, this is the value to
/// use for the next derivation, so keep it at last used + 1.
/// </summary>
public Task<uint> GetCounter(DerivationPurpose purpose, CancellationToken ct = default);

/// <inheritdoc cref="GetCounter"/>
public Task<(uint oldValue, uint newValue)> FetchAndIncrement(
DerivationPurpose purpose,
uint bumpBy = 1,
CancellationToken ct = default
);

/// <inheritdoc cref="GetCounter"/>
public Task SetCounter(
DerivationPurpose purpose,
uint counter,
CancellationToken ct = default
);
}
8 changes: 8 additions & 0 deletions DotNut/Abstractions/Interfaces/IMintQuoteBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ public interface IMintQuoteBuilder
/// </summary>
IMintQuoteBuilder WithP2PkLock(P2PkBuilder p2pkBuilder);

/// <summary>
/// Optional. Like <see cref="WithP2PkLock"/>, but the key to lock to is derived from the
/// wallet seed at the next NUT-13 P2PK counter, so it can be recovered during a restore.
/// The derived key becomes the primary one; any pubkeys already on the builder are kept
/// after it. Requires a mnemonic and a counter implementing <see cref="IDerivationCounter"/>.
/// </summary>
IMintQuoteBuilder WithDeterministicP2PkLock(P2PkBuilder? p2pkBuilder = null);

/// <summary>
/// Optional. When minting P2Pk / HTLC Proofs allows to blind the pubkeys.
/// </summary>
Expand Down
7 changes: 7 additions & 0 deletions DotNut/Abstractions/Interfaces/IWalletBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,13 @@ Task<SendResponse> SelectProofsToSend(
/// <returns></returns>
ICounter? GetCounter();

/// <summary>
/// Returns the current Counter instance as an <see cref="IDerivationCounter"/>, or null when
/// it does not support keyset-independent derivation counters.
/// </summary>
/// <returns></returns>
IDerivationCounter? GetDerivationCounter();

/// <summary>
/// Create swap transaction builder.
/// </summary>
Expand Down
47 changes: 44 additions & 3 deletions DotNut/Abstractions/MintQuoteBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
using DotNut.Api;
using DotNut.ApiModels;
using DotNut.ApiModels.Mint.bolt12;
using DotNut.NBitcoin.BIP39;
using DotNut.NUT13;

namespace DotNut.Abstractions;

Expand All @@ -24,6 +26,7 @@ class MintQuoteBuilder : IMintQuoteBuilder
//for p2pk
private P2PkBuilder? _builder;
private bool _shouldBlind = false;
private bool _deterministicP2Pk = false;

public MintQuoteBuilder(Wallet wallet)
{
Expand Down Expand Up @@ -82,6 +85,13 @@ public IMintQuoteBuilder WithP2PkLock(P2PkBuilder p2pkBuilder)
return this;
}

public IMintQuoteBuilder WithDeterministicP2PkLock(P2PkBuilder? p2pkBuilder = null)
{
this._builder = p2pkBuilder ?? new P2PkBuilder();
this._deterministicP2Pk = true;
return this;
}

public IMintQuoteBuilder BlindPubkeys(bool withBlinding = true)
{
this._shouldBlind = withBlinding;
Expand Down Expand Up @@ -132,7 +142,7 @@ await this._wallet.GetActiveKeysetId(this._unit, ct)
await this._wallet.GetKeys(this._keysetId, true, false, ct)
?? throw new ArgumentException($"Cant get keys for keysetId: {_keysetId}");

var outputs = await this._createOutputs();
var outputs = await this._createOutputs(ct);

var reqBolt11 = new PostMintQuoteBolt11Request()
{
Expand Down Expand Up @@ -189,7 +199,7 @@ await this._wallet.GetKeys(this._keysetId, true, false, ct)
?? throw new ArgumentException($"Cant fetch keys for keysetId: {_keysetId}");
}

var outputs = await this._createOutputs();
var outputs = await this._createOutputs(ct);

var req = new PostMintQuoteBolt12Request()
{
Expand All @@ -205,8 +215,34 @@ await this._wallet.GetKeys(this._keysetId, true, false, ct)
return new MintHandlerBolt12(this._wallet, mintQuote, this._keyset, outputs);
}

/// <summary>
/// Derives the P2PK key from the wallet seed and makes it the primary key on the builder.
/// Consumes one counter value, so this must happen exactly once per quote.
/// </summary>
async Task _applyDeterministicP2PkKey(CancellationToken ct)
{
var mnemonic =
this._wallet.GetMnemonic()
?? throw new ArgumentNullException(
nameof(Mnemonic),
"Can't derive a P2PK lock without a mnemonic"
);

var counter =
this._wallet.GetDerivationCounter()
?? throw new ArgumentNullException(
nameof(IDerivationCounter),
"Can't derive a P2PK lock without a counter implementing IDerivationCounter"
);

var (current, _) = await counter.FetchAndIncrement(DerivationPurpose.P2Pk, 1, ct);
var derived = mnemonic.DeriveP2PkPrivkey(current).Key.CreatePubKey();

this._builder!.Pubkeys = [derived, .. this._builder.Pubkeys ?? []];
}

// skipped checks for keysetid and keys, since its validated before. make sure to remember about it.
async Task<List<OutputData>> _createOutputs()
async Task<List<OutputData>> _createOutputs(CancellationToken ct = default)
{
var outputs = new List<OutputData>();

Expand Down Expand Up @@ -235,6 +271,11 @@ async Task<List<OutputData>> _createOutputs()
return await _wallet.CreateOutputs(_amounts, this._keysetId!);
}

if (this._deterministicP2Pk)
{
await _applyDeterministicP2PkKey(ct);
}

if (this._shouldBlind)
{
if (this._builder.SigFlag == "SIG_ALL")
Expand Down
Loading