Skip to content

wallets: consider hybrid signer composition (local + remote per descriptor) #112

@Kukks

Description

@Kukks

Context

PR #107 establishes a binary split between local and remote signing: a wallet's signing material lives in one place. DefaultWalletProvider.GetSignerAsync returns:

Wallet.Secret Transport claims it Signer
present (ignored) local (HD or single-key)
null/empty yes RemoteArkadeWalletSigner
null/empty no null (watch-only)

This is correct and simpler to reason about as phase 1. It does, however, foreclose a class of use cases worth designing for explicitly rather than ad-hoc later.

The hybrid model

Push key resolution down one layer: an IPrivateKeyProvider resolves "the private key for this descriptor", and a single LocalArkadeWalletSigner composes a set of providers (try each, first hit wins). Concretely:

public interface IPrivateKeyProvider
{
    Task<bool> CanProvideAsync(OutputDescriptor descriptor, CancellationToken ct = default);
    Task<ECPrivKey> GetPrivKeyAsync(OutputDescriptor descriptor, CancellationToken ct = default);
}

Implementations:

  • Bip39KeyProvider(mnemonic) — derives anything below the wallet's account descriptor.
  • NsecKeyProvider(nsec) — provides exactly its single key.
  • RemoteTransportKeyProvider(IRemoteSignerTransport, walletId) — delegates to the multi-wallet transport for descriptors it doesn't have local material for.

Then LocalArkadeWalletSigner becomes:

public class LocalArkadeWalletSigner(IReadOnlyList<IPrivateKeyProvider> providers) : IArkadeWalletSigner
{
    private async Task<IPrivateKeyProvider> Resolve(OutputDescriptor d, CancellationToken ct)
    {
        foreach (var p in providers)
            if (await p.CanProvideAsync(d, ct)) return p;
        throw new InvalidOperationException($"No provider for descriptor {d}.");
    }
    // …
}

Concrete use cases this unblocks

  1. HWI / hardware wallet integration — local mnemonic derives view-key paths cheaply; spend-key paths live on the device behind a RemoteTransportKeyProvider.
  2. Multi-party signing — some cosigners local, some remote, single signer instance bridges them.
  3. Recovery flows — historical paths held locally (BIP-39 mnemonic the user already has), new paths routed to a remote signer policy.
  4. Threshold / key-splitting — extends to N providers with a quorum policy on top.

Trade-offs vs. the current binary design

  • Pro: composability; the dual axis (derivation flavor × signing capability) becomes truly independent rather than sequentially-resolved.
  • Con: every signing call pays a per-descriptor "who provides this?" dispatch. Tractable, but a layer of indirection in the hot path.
  • Migration: HierarchicalDeterministicWalletSigner, NSecWalletSigner, RemoteArkadeWalletSigner collapse into provider implementations behind one signer class. Consumers of IArkadeWalletSigner are unaffected (the interface doesn't change).
  • Interaction with security: MuSig2 private nonce leaks across IRemoteSignerTransport boundary #111: the MuSig2 nonce redesign and this design touch the same surface; sequencing them matters. Likely security: MuSig2 private nonce leaks across IRemoteSignerTransport boundary #111 first (smaller change to the contract), then the hybrid refactor on top.

Reference

Raised during the PR #107 review discussion about why RemoteArkadeWalletSigner exists as a separate class rather than something the local signers compose internally. Filed so the architectural question can be discussed without holding PR #107's WalletType-as-capability rework.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions