Skip to content

Commit 52ff277

Browse files
committed
feat(wallets): compose signers from IPrivateKeyProviders
Closes #112. Pushes signer composition down one layer: an IArkadeWalletSigner is now always a CompositeArkadeWalletSigner built from one or more IPrivateKeyProviders. Each provider answers CanProvideAsync(descriptor) and exposes the signing operations rooted in the key it owns; the composite dispatches each call to the first provider that claims the descriptor. The three previous concrete signers collapse into providers behind the same composite: - Bip39KeyProvider (was HierarchicalDeterministicWalletSigner) claim by master fingerprint match on the descriptor's origin. - NsecKeyProvider (was NSecWalletSigner) claim by x-only pubkey match on tr() descriptors. - RemoteTransportKeyProvider (was RemoteArkadeWalletSigner) claim by IRemoteSignerTransport.KnowsWalletAsync; passthrough proxy. DefaultWalletProvider builds the composition automatically — adds the matching local provider if Wallet.Secret is set, then a remote provider if an IRemoteSignerTransport claims the wallet. Order is significant: local providers get first refusal, remote is fallback. This is what makes hybrid setups (e.g. local view-key paths + HWI spend path) expressible at all — the previous binary local/remote split foreclosed them. The interface keeps IArkadeWalletSigner unchanged for SDK consumers (the TreeSignerSession path is unaffected); the new abstraction is added one layer below. The three old concrete signer classes are deleted since they're equivalent to constructing a CompositeArkadeWalletSigner with the matching single provider — NsecKeyProvider.FromNsec is the new entry point that mirrors the old NSecWalletSigner.FromNsec static. The IPrivateKeyProvider shape is operation-level (Sign/SignMusig/ GenerateNonces) rather than returning a raw ECPrivKey, so a remote provider can implement it honestly without round-tripping secret material over the wire — the original sketch in #112 had IPrivateKeyProvider return ECPrivKey, which would have defeated the point of remote signing. Each local provider keeps its own per-session ConcurrentDictionary nonce store with the same sessionId-keyed semantics established in #111.
1 parent 095ad33 commit 52ff277

12 files changed

Lines changed: 466 additions & 316 deletions
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using NBitcoin;
2+
using NBitcoin.Scripting;
3+
using NBitcoin.Secp256k1;
4+
using NBitcoin.Secp256k1.Musig;
5+
6+
namespace NArk.Abstractions.Wallets;
7+
8+
/// <summary>
9+
/// A descriptor-scoped source of signing operations. The provider owns the private key (locally
10+
/// or by reference) and exposes the signer operations rooted in it — it never returns the raw
11+
/// key, which is what lets a remote-signing implementation honour the same contract.
12+
/// <para>
13+
/// A wallet's signer is a composition of one or more providers (see
14+
/// <see cref="IArkadeWalletSigner"/> implementations that compose providers). Each provider
15+
/// answers <see cref="CanProvideAsync"/> for the descriptors it owns; the composing signer
16+
/// dispatches each call to the first provider that claims the descriptor.
17+
/// </para>
18+
/// <para>
19+
/// The MuSig2 nonce lifecycle from <see cref="IArkadeWalletSigner"/> applies per-provider:
20+
/// <see cref="GenerateNoncesAsync"/> retains the secret half indexed by <c>sessionId</c>, and
21+
/// <see cref="SignMusigAsync"/> consumes it on use. Different providers maintain independent
22+
/// nonce stores — there is no cross-provider sharing.
23+
/// </para>
24+
/// </summary>
25+
public interface IPrivateKeyProvider
26+
{
27+
/// <summary>
28+
/// Returns <c>true</c> iff this provider owns signing material for <paramref name="descriptor"/>.
29+
/// Implementations should make this cheap (fingerprint / x-only match) — it is called on
30+
/// every dispatch and may run against the BIP-32 / BIP-39 derivation path of long descriptors.
31+
/// </summary>
32+
Task<bool> CanProvideAsync(OutputDescriptor descriptor, CancellationToken cancellationToken = default);
33+
34+
/// <summary>
35+
/// Gets the compressed public key for <paramref name="descriptor"/>, preserving parity.
36+
/// </summary>
37+
Task<ECPubKey> GetPubKeyAsync(OutputDescriptor descriptor, CancellationToken cancellationToken = default);
38+
39+
/// <summary>
40+
/// Produces a BIP-340 Schnorr signature over <paramref name="hash"/> using the descriptor's
41+
/// private key, returning the x-only pubkey alongside the signature.
42+
/// </summary>
43+
Task<(ECXOnlyPubKey, SecpSchnorrSignature)> SignAsync(
44+
OutputDescriptor descriptor,
45+
uint256 hash,
46+
CancellationToken cancellationToken = default);
47+
48+
/// <summary>
49+
/// Generates a fresh MuSig2 nonce pair for <paramref name="context"/>, retains the secret
50+
/// half indexed by <paramref name="sessionId"/>, and returns the public half. See
51+
/// <see cref="IArkadeWalletSigner.GenerateNonces"/> for the lifecycle contract.
52+
/// </summary>
53+
Task<MusigPubNonce> GenerateNoncesAsync(
54+
OutputDescriptor descriptor,
55+
MusigContext context,
56+
string sessionId,
57+
CancellationToken cancellationToken = default);
58+
59+
/// <summary>
60+
/// Produces a MuSig2 partial signature using the secret nonce generated for the same
61+
/// <paramref name="sessionId"/>. The secret nonce is consumed on this call. See
62+
/// <see cref="IArkadeWalletSigner.SignMusig"/> for the lifecycle contract.
63+
/// </summary>
64+
Task<MusigPartialSignature> SignMusigAsync(
65+
OutputDescriptor descriptor,
66+
MusigContext context,
67+
string sessionId,
68+
CancellationToken cancellationToken = default);
69+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using NArk.Abstractions.Wallets;
2+
using NBitcoin;
3+
using NBitcoin.Scripting;
4+
using NBitcoin.Secp256k1;
5+
using NBitcoin.Secp256k1.Musig;
6+
7+
namespace NArk.Core.Wallet;
8+
9+
/// <summary>
10+
/// An <see cref="IArkadeWalletSigner"/> composed of one or more <see cref="IPrivateKeyProvider"/>s.
11+
/// For every signing call the composite resolves the first provider whose
12+
/// <see cref="IPrivateKeyProvider.CanProvideAsync"/> returns <c>true</c> and dispatches the
13+
/// operation to it. Provider order is significant — earlier providers take precedence — so
14+
/// callers should register the cheapest / most-local provider first and any fallbacks
15+
/// (e.g. remote-signer transports) last.
16+
/// </summary>
17+
public class CompositeArkadeWalletSigner : IArkadeWalletSigner
18+
{
19+
private readonly IReadOnlyList<IPrivateKeyProvider> _providers;
20+
21+
public CompositeArkadeWalletSigner(IEnumerable<IPrivateKeyProvider> providers)
22+
{
23+
_providers = (providers ?? throw new ArgumentNullException(nameof(providers))).ToArray();
24+
if (_providers.Count == 0)
25+
throw new ArgumentException("At least one provider is required.", nameof(providers));
26+
}
27+
28+
public CompositeArkadeWalletSigner(params IPrivateKeyProvider[] providers)
29+
: this((IEnumerable<IPrivateKeyProvider>)providers)
30+
{
31+
}
32+
33+
public async Task<ECPubKey> GetPubKey(OutputDescriptor descriptor, CancellationToken cancellationToken = default)
34+
=> await (await ResolveAsync(descriptor, cancellationToken)).GetPubKeyAsync(descriptor, cancellationToken);
35+
36+
public async Task<(ECXOnlyPubKey, SecpSchnorrSignature)> Sign(OutputDescriptor descriptor, uint256 hash, CancellationToken cancellationToken = default)
37+
=> await (await ResolveAsync(descriptor, cancellationToken)).SignAsync(descriptor, hash, cancellationToken);
38+
39+
public async Task<MusigPubNonce> GenerateNonces(OutputDescriptor descriptor, MusigContext context, string sessionId, CancellationToken cancellationToken = default)
40+
=> await (await ResolveAsync(descriptor, cancellationToken)).GenerateNoncesAsync(descriptor, context, sessionId, cancellationToken);
41+
42+
public async Task<MusigPartialSignature> SignMusig(OutputDescriptor descriptor, MusigContext context, string sessionId, CancellationToken cancellationToken = default)
43+
=> await (await ResolveAsync(descriptor, cancellationToken)).SignMusigAsync(descriptor, context, sessionId, cancellationToken);
44+
45+
private async Task<IPrivateKeyProvider> ResolveAsync(OutputDescriptor descriptor, CancellationToken cancellationToken)
46+
{
47+
foreach (var provider in _providers)
48+
{
49+
if (await provider.CanProvideAsync(descriptor, cancellationToken).ConfigureAwait(false))
50+
return provider;
51+
}
52+
53+
throw new InvalidOperationException(
54+
$"No registered IPrivateKeyProvider claims descriptor '{descriptor}'. " +
55+
"Either the wallet is watch-only for this path, or a provider is missing from the composition.");
56+
}
57+
}

NArk.Core/Wallet/DefaultWalletProvider.cs

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using NArk.Abstractions.Safety;
66
using NArk.Abstractions.Wallets;
77
using NArk.Core.Transport;
8+
using NArk.Core.Wallet.PrivateKeyProviders;
89
using NBitcoin;
910

1011
namespace NArk.Core.Wallet;
@@ -30,9 +31,9 @@ public class DefaultWalletProvider(
3031
: IWalletProvider
3132
{
3233
// Signer instances must be reused across calls so the MuSig2 secret-nonce store on each
33-
// signer (populated by GenerateNonces, consumed by SignMusig — see IArkadeWalletSigner)
34-
// survives between the two calls. The cache key includes the wallet's secret so a wallet
35-
// re-imported with a different mnemonic gets a fresh signer.
34+
// provider (populated by GenerateNonces, consumed by SignMusig — see IArkadeWalletSigner)
35+
// survives between the two calls. The cache key includes a hash of the wallet's secret so
36+
// a wallet re-imported with different signing material gets a fresh signer.
3637
private readonly ConcurrentDictionary<string, IArkadeWalletSigner> _signerCache = new();
3738

3839
public async Task<IArkadeWalletSigner?> GetSignerAsync(string identifier, CancellationToken cancellationToken = default)
@@ -46,30 +47,34 @@ public class DefaultWalletProvider(
4647
logger?.LogDebug("GetSignerAsync: identifier={Identifier}, walletId={WalletId}, walletType={WalletType}, hasSecret={HasSecret}",
4748
identifier, wallet.Id, wallet.WalletType, !string.IsNullOrEmpty(wallet.Secret));
4849

49-
// Local signing material present → produce a local signer of the right shape.
50+
var providers = new List<IPrivateKeyProvider>();
51+
52+
// Local signing material present → add the matching local provider.
5053
if (!string.IsNullOrEmpty(wallet.Secret))
5154
{
52-
var cacheKey = $"local:{wallet.Id}:{wallet.Secret.GetHashCode():x}";
53-
return _signerCache.GetOrAdd(cacheKey, _ => wallet.WalletType switch
55+
providers.Add(wallet.WalletType switch
5456
{
55-
WalletType.HD => new HierarchicalDeterministicWalletSigner(wallet),
56-
WalletType.SingleKey => NSecWalletSigner.FromNsec(wallet.Secret, logger),
57+
WalletType.HD => new Bip39KeyProvider(wallet.Secret),
58+
WalletType.SingleKey => NsecKeyProvider.FromNsec(wallet.Secret, logger),
5759
_ => throw new ArgumentOutOfRangeException(nameof(wallet.WalletType))
5860
});
5961
}
6062

61-
// No local secret → ask the remote-signer transport (if any) whether it can sign for
62-
// this wallet. The transport is the source of truth for "remote vs watch-only" —
63-
// no flag on ArkWalletInfo encodes it.
63+
// Remote-signer transport claims this wallet → add a remote provider as a fallback
64+
// for descriptors no local provider covers. Order is significant: local providers
65+
// get first refusal.
6466
if (remoteSignerTransport is not null
6567
&& await remoteSignerTransport.KnowsWalletAsync(wallet.Id, cancellationToken).ConfigureAwait(false))
6668
{
67-
return _signerCache.GetOrAdd($"remote:{wallet.Id}",
68-
_ => new RemoteArkadeWalletSigner(wallet.Id, remoteSignerTransport));
69+
providers.Add(new RemoteTransportKeyProvider(remoteSignerTransport, wallet.Id));
6970
}
7071

71-
// No local key, no remote signer claims it → watch-only.
72-
return null;
72+
// No provider can sign for this wallet → watch-only.
73+
if (providers.Count == 0)
74+
return null;
75+
76+
var cacheKey = $"{wallet.Id}:{wallet.Secret?.GetHashCode():x}:{remoteSignerTransport?.GetHashCode():x}";
77+
return _signerCache.GetOrAdd(cacheKey, _ => new CompositeArkadeWalletSigner(providers));
7378
}
7479
catch (KeyNotFoundException)
7580
{

NArk.Core/Wallet/HierarchicalDeterministicWalletSigner.cs

Lines changed: 0 additions & 76 deletions
This file was deleted.

0 commit comments

Comments
 (0)