Wallets
Wallets are stored in IWalletStorage and materialized as address/signing providers on demand by IWalletProvider (default: DefaultWalletProvider).
Two orthogonal axes describe any wallet — keep them separate at every layer:
1. Key-derivation flavour (WalletType):
WalletType |
Script shape | Use case |
|---|---|---|
SingleKey |
tr(pubkey) — one flat key |
Static key, simple integrations |
HD |
tr([fp/path]xpub/0/*) — xpub-derived child set |
Per-contract derivation, boarding support |
2. Signing capability — answered by IWalletProvider.GetSignerAsync, not by the data type:
ArkWalletInfo.Secret |
IRemoteSignerTransport claims it |
GetSignerAsync returns |
Capability |
|---|---|---|---|
| non-empty | — | CompositeArkadeWalletSigner with the matching local signing source |
sign locally |
| null / empty | yes (KnowsWalletAsync → true) |
composite with one RemoteTransportSigningSource |
sign via transport |
| null / empty | no | null |
watch-only |
Capability lives at the signing-source boundary, not as a tag on the wallet record. Any combination of the two axes is valid — a remote-signed SingleKey, a watch-only HD, etc.
Signer composition
IArkadeWalletSigner is always a CompositeArkadeWalletSigner built from one or more
IDescriptorSigningSources. Each source answers CanProvideAsync(descriptor) and exposes
the signer operations rooted in the key it owns; the composite dispatches each call to the
first source that claims the descriptor (order is significant — register local sources first).
Three sources ship by default:
| Signing source | Origin | CanProvideAsync |
|---|---|---|
Bip39SigningSource |
BIP-39 mnemonic | descriptor origin's master fingerprint matches |
NsecSigningSource |
Nostr nsec (single key) |
descriptor's x-only pubkey matches |
RemoteTransportSigningSource |
IRemoteSignerTransport |
transport.KnowsWalletAsync(walletId) |
DefaultWalletProvider builds this composition automatically from the wallet record. To
extend — e.g. plug a hardware wallet in alongside a local mnemonic — implement
IDescriptorSigningSource and construct the composite manually (or replace
DefaultWalletProvider).
HD Wallets (BIP-39)
Created from a BIP-39 mnemonic. The SDK derives per-contract keys along BIP-86 style derivation (m/86'/coin'/0'), giving:
- Unique address per invoice (privacy)
- Boarding address support (on-chain → Arkade)
- Deterministic recovery from the mnemonic
SingleKey Wallets
Created from a Nostr nsec (raw 32-byte secret). All operations use a single static key:
- Simpler setup
- No boarding address support
- Suitable for testing or lightweight integrations
Watch-Only and Remote-Signed Wallets
Both are described by the same data shape — Secret = null on an otherwise normal ArkWalletInfo — and distinguished at runtime by IWalletProvider.GetSignerAsync:
- No
IRemoteSignerTransportregistered, orKnowsWalletAsync(walletId)returnsfalse→GetSignerAsyncreturnsnull. Watch-only: addresses and VTXOs are observable, signing-dependent operations (batch participation, unilateral exits) throw a descriptiveInvalidOperationException. - An
IRemoteSignerTransportis registered and claims the wallet →GetSignerAsyncreturns aCompositeArkadeWalletSignerwrapping aRemoteTransportSigningSource. Every signing call is forwarded to the transport. The transport seeswalletIdon every call so one instance can serve many wallets (server-side signing service, HWI bridge, browser-extension wallet, …).
WalletType is independent: a watch-only HD wallet derives addresses from its xpub; a watch-only single-key wallet has one fixed address. Same for remote — derivation is whatever the descriptor encodes; the signer-source is whatever the transport claims.
Creating a Wallet
WalletFactory.CreateWallet is a static helper that inspects the secret and produces the right ArkWalletInfo record. Persist the resulting record via IWalletStorage:
var serverInfo = await clientTransport.GetServerInfoAsync(ct);
// HD wallet (from a mnemonic). Destination is an optional sweep-to Ark address.
var hd = await WalletFactory.CreateWallet(
walletSecret: mnemonic,
destination: null,
serverInfo: serverInfo,
cancellationToken: ct);
await walletStorage.SaveWallet(hd, ct);
// SingleKey wallet (from a Nostr nsec).
var sk = await WalletFactory.CreateWallet(
walletSecret: "nsec1...",
destination: null,
serverInfo: serverInfo,
cancellationToken: ct);
await walletStorage.SaveWallet(sk, ct);
// Watch-only OR remote-signed: same data shape — null Secret + the descriptor. Whether the
// wallet ends up watch-only or remote-signed is decided at GetSignerAsync time by whether an
// IRemoteSignerTransport is registered and claims this walletId (KnowsWalletAsync).
var nonLocal = await WalletFactory.CreateWatchOnlyWallet(
accountDescriptor: "tr([abcd1234/86'/1'/0']tpub.../0/*)",
destination: null,
serverInfo: serverInfo,
cancellationToken: ct);
await walletStorage.SaveWallet(nonLocal, ct);
ArkWalletInfo.Id is the deterministic wallet identifier derived from the descriptor — two imports of the same seed produce the same Id.
Implementing a Remote Signer
For wallets whose Secret is null, the SDK never sees private material; every signing call is forwarded to an IRemoteSignerTransport you register in DI. The transport itself decides which wallets it can sign for via KnowsWalletAsync — wallets it doesn't claim fall through to watch-only.
Mirror IArkadeWalletSigner with an extra walletId parameter on each method, plus the KnowsWalletAsync probe:
public class HardwareSignerTransport : IRemoteSignerTransport
{
public Task<bool> KnowsWalletAsync(string walletId, CancellationToken ct)
=> _bridge.IsPairedAsync(walletId, ct);
public Task<ECPubKey> GetPubKeyAsync(string walletId, OutputDescriptor descriptor, CancellationToken ct)
=> _bridge.GetPubKeyAsync(walletId, descriptor.ToString(), ct);
public Task<MusigPartialSignature> SignMusigAsync(string walletId, OutputDescriptor descriptor,
MusigContext context, string sessionId, CancellationToken ct)
=> _bridge.SignMusigAsync(walletId, descriptor.ToString(), context, sessionId, ct);
public Task<(ECXOnlyPubKey, SecpSchnorrSignature)> SignAsync(string walletId, OutputDescriptor descriptor,
uint256 hash, CancellationToken ct)
=> _bridge.SignAsync(walletId, descriptor.ToString(), hash, ct);
public Task<MusigPubNonce> GenerateNoncesAsync(string walletId, OutputDescriptor descriptor,
MusigContext context, string sessionId, CancellationToken ct)
=> _bridge.GenerateNoncesAsync(walletId, descriptor.ToString(), context, sessionId, ct);
}
services.AddSingleton<IRemoteSignerTransport, HardwareSignerTransport>();
DefaultWalletProvider accepts the transport as an optional constructor dependency — existing setups that don't use remote signing don't need to register one.
MuSig2 Nonce Lifecycle
The MuSig2 nonce flow keeps the secret half on the signer side: GenerateNoncesAsync retains the
secret nonce, indexed by walletId + a caller-supplied sessionId, and returns only the public
half. SignMusigAsync looks the secret up by the same sessionId and consumes it on use —
calling SignMusigAsync without a prior matching GenerateNoncesAsync throws.
sessionId must be unique per signing operation within the signer's scope. In batch participation,
TreeSignerSession passes each tree-node txid as the sessionId; for other flows the caller picks
something equally disambiguating. MusigContext.AggregatePubKey is not enough on its own —
multiple tree nodes can share cosigner set + tweak, so their contexts have identical aggregate
pubkeys but different sighashes. The sighash is internal to MusigContext and can't be observed
by the signer, so the disambiguator has to come from the caller.
Implementations need an eviction policy for abandoned nonces (TTL or bounded count) so the secret
nonce store does not grow unbounded if a caller generates a nonce but never signs. The in-process
NsecSigningSource / Bip39SigningSource rely on remove-on-consume; long-lived transport
implementations need to add a sweep.
DefaultWalletProvider caches signer instances per wallet so that the secret-nonce store on a
local signing source survives between the GenerateNonces call and the matching SignMusig
call. A fresh signer per call would silently break MuSig2 signing — the second call would
always find an empty store.
Using a Wallet
IWalletProvider exposes wallets as address/signer providers:
var provider = await walletProvider.GetAddressProviderAsync(walletId, ct)
?? throw new InvalidOperationException("Wallet not found");
// Provider gives you contracts / addresses; pair it with ContractService
// to derive and record the contract as a single operation.
Contracts (Receiving Addresses)
Use ContractService.DeriveContract to produce a contract for a specific purpose, persist it, and return it:
var contract = await contractService.DeriveContract(
walletId,
NextContractPurpose.Receive,
ContractActivityState.AwaitingFundsBeforeDeactivate,
metadata: new Dictionary<string, string> { ["Source"] = "invoice" },
cancellationToken: ct);
var arkAddress = contract.GetArkAddress()
.ToString(serverInfo.Network.ChainName == ChainName.Mainnet); // tark1q... / ark1q...
NextContractPurpose values:
Receive— new address for inbound VTXOsBoarding— on-chain address that can be boarded into Arkade (HD wallets only)SendToSelf— change / internal-use contract
See Spending for how to send funds, and Storage for how wallets and contracts are persisted.