Skip to content

xpub delivery and pubkey derivation

One of the main design choices in hdwalletv1 is that the wallet delivers BIP32 extended public keys (xpubs) in wallet_ready and the dapp derives all addresses locally. This page explains the design, the on-demand derivation model, and the change-address gap-fill logic.

Why xpubs instead of individual pubkeys

The old model (pre-xpub)

Earlier relay protocols required the dapp to ask the wallet for pubkeys by sending pubkey_batch requests, waiting for pubkey_batch_response, and tracking "subscribe_paths" subscriptions. This:

  • Added round-trips before the dapp could construct a transaction.
  • Required the wallet to be online and responsive during transaction construction.
  • Created complex state management for "have I received pubkeys for indices N…M?".

The xpub model

The wallet sends one xpub per named path in wallet_ready. The dapp can then derive any child pubkey m/<xpub>/n locally for any index n in O(1) with no network. BIP32 public-child derivation is deterministic and requires no private key material.

Design decision: wallet chooses derivation path. The xpub is delivered with a name (receive, change, defi) but no information about the wallet's internal derivation path. The dapp derives addresses correctly regardless — it never sees the path, only the xpub node.

Highly recommended: standard BIP44 paths. For maximum interoperability — so that funds are found by any BIP44-compatible wallet during seed recovery — wallets should use:

Name Recommended path
receive m/44'/145'/0'/0
change m/44'/145'/0'/1
defi m/44'/145'/0'/7

Privacy alternative: non-standard or rotating paths. A wallet that prioritises privacy may derive xpubs from arbitrary paths, or choose a fresh random derivation path each session. The protocol carries only the xpub, so the dapp is unaffected. The trade-off is that standard recovery tools won't find funds at non-standard paths without extra metadata.

DappPubkeyStateManager

DappPubkeyStateManager (in @wizardconnect/dapp) is the dapp's local address book.

Storage

xpubNodes:        Map<childIndex, HdPublicNodeValid>   — decoded xpub node per path
pubkeys:          Map<childIndex, Map<index, Uint8Array>>  — cached 33-byte compressed pubkeys
addressIndices:   Map<childIndex, bigint>              — current "next index" per path
changeAddressQueue: Set<bigint>                        — gap-fill queue for change path

On-demand derivation

getPubkey(childIndex, index) checks the cache first. On a miss it derives the child key from the xpub node:

const child = deriveHdPublicNodeChild(xpubNode, Number(index));
// child.publicKey is the 33-byte compressed secp256k1 public key

The derived key is added to the cache so subsequent calls are instant. This means the dapp never pre-fetches — it derives exactly the indices it needs, when it needs them.

getIndexToUse

getIndexToUse(childIndex, options?): bigint

Returns the "right" index for the next address on a path, with three cases in priority order:

  1. If options.index is provided, use it directly (caller knows exactly what they want).
  2. If the path is change (childIndex === 1) and there are gap-fill entries, return the lowest available gap address (see below).
  3. Otherwise return addressIndex + 1 (the next fresh index).

With options.reuseLast = true, returns addressIndex instead of +1 (for reusing the last issued address, e.g., when re-displaying an invoice).

getIndexRange

Returns the min and max indices currently cached for a path. Useful for knowing the spread of addresses the dapp has worked with.

Change address gap-filling

Change addresses are spend-once by convention. When a transaction is constructed but not yet broadcast, the dapp allocates a change address. If the transaction is later abandoned, that change index becomes a "gap" — it was never actually used on-chain, but the dapp has already handed it out. Requesting a new fresh index from addressIndex + 1 would skip the gap, wasting an address and potentially causing the wallet to over-scan.

How it works

addPubkey(1, index, pubkey) checks whether index < currentAddressIndex. If so, the address is in the "past" — it was issued earlier but not yet used (a gap). It gets added to changeAddressQueue.

getIndexToUse(1) checks changeAddressQueue first and returns a gap address if one exists and its pubkey is cached. The caller calls removeFromChangeQueue(index) once the address is actually used in a broadcast transaction.

This is primarily useful for dapps that optimistically allocate change addresses before confirming broadcast. If the user cancels mid-flow, the gap address goes back into the queue.

BIP32 derivation depth

The xpub delivered in wallet_ready is the path node — for example, the node at m/44'/145'/0'/0 for the receive path. Deriving child index n from it gives the same key as deriving m/44'/145'/0'/0/n from the master key. The /n level is the address level (unhardened).

BIP32 requires unhardened derivation for xpub child derivation (hardened derivation requires the private key). Standard BIP44 address indices are always unhardened, so this works correctly.

Example: deriving receive addresses

// After wallet_ready:
const RECEIVE = 0;  // childIndex for receive

// Derive address at index 0:
const pubkey = dappMgr.getPubkey(RECEIVE, 0n);
// pubkey is a 33-byte Uint8Array (compressed secp256k1 public key)

// Derive several addresses for display (no network needed):
for (let i = 0n; i < 5n; i++) {
  const pk = dappMgr.getPubkey(RECEIVE, i);
  // convert to cashaddr, P2PKH, etc.
}

Integration test coverage

integration.test.ts and pubkeybatch.test.ts (in packages/wallet/src/integration/) verify:

  • wallet_ready carries paths for all three named paths (receive, change, defi).
  • Each xpub is valid base58 and decodes as a valid BIP32 node.
  • Deriving child indices from the xpub matches adapter.getPublicKey(path, index) exactly.