142 lines
6.1 KiB
Markdown
142 lines
6.1 KiB
Markdown
# 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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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.
|