# Bitcoin Armory Derivation And Key-Material Matrix

Date: 2026-06-30

Purpose: capture the current source of truth for how the app derives keys, exports public material, builds descriptors, stamps PSBT metadata, and re-identifies spendable outputs offline.

This document is descriptive, not normative. It records current code behavior to support the audit in [crypto-audit-plan.md](/Users/juthicamallela/bitcoinarmory/docs/security/crypto-audit-plan.md:1).

## 1. Wallet roles

### Offline wallet

Primary purpose:

1. hold master private material
2. derive receive and change addresses
3. derive Silent Payment scan and spend private keys
4. sign PSBTs
5. resolve Silent Payment placeholder outputs into actual Taproot outputs

Primary code:

- [core/wallet.py](/Users/juthicamallela/bitcoinarmory/core/wallet.py:57)
- [offline/signer.py](/Users/juthicamallela/bitcoinarmory/offline/signer.py:31)

### Online watch-only wallet

Primary purpose:

1. derive public receive and change addresses from account xpubs
2. import descriptors into Bitcoin Core
3. construct PSBTs
4. detect Silent Payment outputs using the scan private key
5. persist SP detected UTXO metadata, including tweak

Primary code:

- [core/wallet.py](/Users/juthicamallela/bitcoinarmory/core/wallet.py:108)
- [online/setup.py](/Users/juthicamallela/bitcoinarmory/online/setup.py:1700)

## 2. Current derivation families

| Family | Account path | Leaf path | Output type | Where derived |
|---|---|---|---|---|
| BIP84 | `m/84'/coin'/0'` | `/change/index` | P2WPKH | [core/wallet.py](/Users/juthicamallela/bitcoinarmory/core/wallet.py:173) |
| BIP86 | `m/86'/coin'/0'` | `/change/index` | P2TR key-path | [core/wallet.py](/Users/juthicamallela/bitcoinarmory/core/wallet.py:173) |
| BIP352 spend | `m/352'/coin'/0'` | `/0'/0` | SP spend base key | [core/silent_payment.py](/Users/juthicamallela/bitcoinarmory/core/silent_payment.py:43) |
| BIP352 scan | `m/352'/coin'/0'` | `/1'/0` | SP scan key | [core/silent_payment.py](/Users/juthicamallela/bitcoinarmory/core/silent_payment.py:43) |

Notes:

1. `coin` is `0` for mainnet and `1` for testnet.
2. BIP84 and BIP86 use unhardened `/change/index` below the account node.
3. Silent Payments do not currently derive per-index receive addresses. The public receive address is built from one scan pubkey and one spend pubkey, and incoming outputs are individualized by sender-derived tweaks.

## 3. Full-wallet material

When a full wallet exists in memory, it contains or can derive:

| Material | Present in memory | Saved with `include_private=True` | Saved with `include_private=False` |
|---|---|---|---|
| master xprv | yes | yes | no |
| account xpubs for BIP84 and BIP86 | yes | yes | yes |
| master fingerprint | yes | yes | yes |
| BIP84 address cache | yes | yes | yes |
| BIP86 address cache | yes | yes | yes |
| SP scan private key | yes | yes | yes |
| SP scan public key | yes | yes | yes |
| SP spend public key | yes | yes | yes |
| SP spend private key | yes | yes | no |
| SP detected UTXOs | maybe | yes | yes |

Primary code:

- [core/wallet.py](/Users/juthicamallela/bitcoinarmory/core/wallet.py:360)
- [core/wallet.py](/Users/juthicamallela/bitcoinarmory/core/wallet.py:401)

Audit relevance:

1. `include_private=False` still saves `sp_scan_privkey`.
2. That is intentional for online SP detection, but it is a key-material boundary that needs explicit threat-model approval.

## 4. Watch-only export material

`export_watch_only()` produces a wallet file with:

| Material | Exported | Why |
|---|---|---|
| BIP84 account xpub | yes | derive receive/change addresses online |
| BIP86 account xpub | yes | derive Taproot receive/change addresses online |
| master fingerprint | yes | PSBT origin metadata |
| SP scan private key | yes | detect incoming SP outputs online |
| SP scan public key | yes | retained wallet metadata |
| SP spend public key | yes | detect which Taproot outputs belong to wallet |
| SP spend private key | no | must never leave offline device |

Primary code:

- [core/wallet.py](/Users/juthicamallela/bitcoinarmory/core/wallet.py:460)

Additional behavior:

1. watch-only export pre-generates BIP84 and BIP86 addresses using `generate_addresses()`
2. online import also generates 100 addresses for each of BIP84 and BIP86 after loading the watch-only file

Code:

- [core/wallet.py](/Users/juthicamallela/bitcoinarmory/core/wallet.py:476)
- [online/setup.py](/Users/juthicamallela/bitcoinarmory/online/setup.py:1722)

## 5. Address derivation behavior

### BIP84

Steps:

1. derive account node `m/84'/coin'/0'`
2. derive child `/change/index`
3. compute compressed pubkey
4. compute HASH160(pubkey)
5. build P2WPKH script
6. encode bech32 address

Code:

- [core/wallet.py](/Users/juthicamallela/bitcoinarmory/core/wallet.py:179)

### BIP86

Steps:

1. derive account node `m/86'/coin'/0'`
2. derive child `/change/index`
3. compute compressed internal pubkey
4. convert to x-only
5. compute Taproot tweak
6. build P2TR script from tweaked x-only key
7. encode bech32m address

Code:

- [core/wallet.py](/Users/juthicamallela/bitcoinarmory/core/wallet.py:179)

Important distinction:

1. the leaf derived from BIP86 is the internal key
2. the address and script correspond to the tweaked output key
3. spending later must use the correspondingly tweaked private key

### Silent Payment address

Steps:

1. derive SP scan private/public key from `m/352'/coin'/0'/1'/0`
2. derive SP spend private/public key from `m/352'/coin'/0'/0'/0`
3. encode address payload as `scan_xonly || spend_xonly`
4. encode using bech32m HRP `sp` or `tsp`

Code:

- [core/silent_payment.py](/Users/juthicamallela/bitcoinarmory/core/silent_payment.py:43)
- [core/silent_payment.py](/Users/juthicamallela/bitcoinarmory/core/silent_payment.py:77)

## 6. Descriptor construction

The online side currently builds descriptors only for BIP84 and BIP86 account xpubs.

| Path contains | Descriptor function | Receive branch | Change branch |
|---|---|---|---|
| `84'` | `wpkh` | `.../0/*` | `.../1/*` |
| `86'` | `tr` | `.../0/*` | `.../1/*` |

Descriptor origin data:

1. fingerprint is either the supplied master fingerprint or the xpub's parent fingerprint
2. origin path is the stored path with `'` rewritten to `h`

Code:

- [online/setup.py](/Users/juthicamallela/bitcoinarmory/online/setup.py:532)

Implication:

1. Bitcoin Core descriptor import does not represent Silent Payments
2. Silent Payment detection is handled separately by Armory logic, not via Core descriptors

## 7. PSBT metadata written by the online side

### Inputs

For standard wallet-owned inputs:

| Address type | PSBT metadata written |
|---|---|
| BIP84 | `PSBT_IN_BIP32_DERIVATION(pubkey -> fingerprint + path)` |
| BIP86 | `PSBT_IN_TAP_BIP32_DERIVATION(xonly -> zero leaf hashes + fingerprint + path)` and `PSBT_IN_TAP_INTERNAL_KEY(xonly)` |

Code:

- [online/setup.py](/Users/juthicamallela/bitcoinarmory/online/setup.py:4815)
- [core/tx.py](/Users/juthicamallela/bitcoinarmory/core/tx.py:455)
- [core/tx.py](/Users/juthicamallela/bitcoinarmory/core/tx.py:493)

For Silent Payment UTXOs being spent:

| Metadata | Purpose |
|---|---|
| `PSBT_IN_SP_TWEAK` | lets offline signer derive the SP spending key from `sp_spend_privkey + tweak` |

Code:

- [online/setup.py](/Users/juthicamallela/bitcoinarmory/online/setup.py:2231)
- [core/tx.py](/Users/juthicamallela/bitcoinarmory/core/tx.py:362)

### Outputs

For standard change outputs:

| Address type | PSBT metadata written |
|---|---|
| BIP84 change | `PSBT_OUT_BIP32_DERIVATION(pubkey -> fingerprint + path)` |
| BIP86 change | `PSBT_OUT_TAP_BIP32_DERIVATION(xonly -> zero leaf hashes + fingerprint + path)` |

Code:

- [online/setup.py](/Users/juthicamallela/bitcoinarmory/online/setup.py:4781)
- [core/tx.py](/Users/juthicamallela/bitcoinarmory/core/tx.py:466)
- [core/tx.py](/Users/juthicamallela/bitcoinarmory/core/tx.py:479)

For Silent Payment recipients:

| Metadata | Purpose |
|---|---|
| `PSBT_OUT_SP_ADDRESS_PREFIX` | stores the recipient SP address as proprietary output data so the offline signer can compute the actual P2TR destination |

Code:

- [online/setup.py](/Users/juthicamallela/bitcoinarmory/online/setup.py:2250)
- [core/tx.py](/Users/juthicamallela/bitcoinarmory/core/tx.py:361)

## 8. How the offline signer finds keys

The offline signer has several distinct resolution paths.

### Input private-key resolution

Resolution order in `_find_input_privkey()`:

1. if `PSBT_IN_SP_TWEAK` exists, derive SP spending key from wallet `sp_spend_privkey`
2. otherwise, try standard `PSBT_IN_BIP32_DERIVATION`
3. otherwise, try `PSBT_IN_TAP_BIP32_DERIVATION`
4. otherwise, try witness-UTXO script lookup in stored wallet address cache
5. otherwise, brute-force derive known branches until matching script is found

Code:

- [offline/signer.py](/Users/juthicamallela/bitcoinarmory/offline/signer.py:180)

### P2WPKH signing path

Resolution order in `_sign_p2wpkh()`:

1. inspect `PSBT_IN_BIP32_DERIVATION`
2. verify derived public key exactly matches PSBT pubkey
3. if absent, search stored address cache by script
4. if absent, brute-force derive address branches

Code:

- [offline/signer.py](/Users/juthicamallela/bitcoinarmory/offline/signer.py:284)

### P2TR signing path

Resolution order in `_sign_p2tr()`:

1. if `PSBT_IN_SP_TWEAK` exists, derive SP spending key directly
2. else inspect `PSBT_IN_TAP_BIP32_DERIVATION`
3. verify derived x-only pubkey exactly matches PSBT x-only key
4. if absent, search stored address cache by script
5. if absent, brute-force derive address branches

Code:

- [offline/signer.py](/Users/juthicamallela/bitcoinarmory/offline/signer.py:345)

Important distinction:

1. standard BIP86 Taproot spends apply the TapTweak to the private key before signing
2. SP-derived Taproot spends do not apply the BIP86 tweak path

Code:

- [offline/signer.py](/Users/juthicamallela/bitcoinarmory/offline/signer.py:399)

## 9. How the offline signer identifies change or owned outputs

Current mechanisms:

| Mechanism | Applies to | Source |
|---|---|---|
| output BIP32 derivation in PSBT | BIP84 change | online PSBT builder |
| output Taproot BIP32 derivation in PSBT | BIP86 change | online PSBT builder |
| script match against stored address cache | BIP84/BIP86 outputs already known to wallet | local wallet state |
| extended script scan | BIP84/BIP86 outputs beyond pre-derived window | deterministic derivation |
| SP proprietary output metadata | SP recipient outputs | online PSBT builder, resolved offline |

Code references:

- [online/setup.py](/Users/juthicamallela/bitcoinarmory/online/setup.py:4781)
- [core/wallet.py](/Users/juthicamallela/bitcoinarmory/core/wallet.py:259)
- [offline/signer.py](/Users/juthicamallela/bitcoinarmory/offline/signer.py:89)

## 10. Silent Payment send, detect, and spend flow

### Send

Online side:

1. detect recipient address begins with `sp1` or `tsp1`
2. place placeholder P2TR script in unsigned tx
3. store recipient SP address in PSBT proprietary output field

Offline side:

1. gather private keys for all inputs
2. gather input outpoints
3. if an input is standard BIP86 Taproot, tweak the private key to the output key before SP input aggregation
4. if an input is itself an SP input, do not apply BIP86 tweaking
5. compute actual SP destination output key
6. replace placeholder script with real P2TR script

Code:

- [online/setup.py](/Users/juthicamallela/bitcoinarmory/online/setup.py:2130)
- [offline/signer.py](/Users/juthicamallela/bitcoinarmory/offline/signer.py:89)

### Detect

Online side:

1. use `sp_scan_privkey`
2. use input pubkeys, input outpoints, and Taproot outputs from scanned transactions
3. identify matching outputs
4. store tweak and UTXO metadata for later spend construction

Code:

- [core/silent_payment.py](/Users/juthicamallela/bitcoinarmory/core/silent_payment.py:264)
- [online/setup.py](/Users/juthicamallela/bitcoinarmory/online/setup.py:2033)

### Spend detected SP UTXO

Online side:

1. include SP UTXO among candidate spendable UTXOs
2. attach `PSBT_IN_SP_TWEAK`

Offline side:

1. derive spending key from `sp_spend_privkey` plus tweak
2. treat result as direct Taproot output key
3. sign without BIP86 tap tweak

Code:

- [online/setup.py](/Users/juthicamallela/bitcoinarmory/online/setup.py:2107)
- [offline/signer.py](/Users/juthicamallela/bitcoinarmory/offline/signer.py:352)
- [core/silent_payment.py](/Users/juthicamallela/bitcoinarmory/core/silent_payment.py:350)

## 11. Initial audit-sensitive observations

These are observations to investigate, not final findings.

1. The online watch-only wallet intentionally stores `sp_scan_privkey`. That is required for current detection logic, but it is materially different from a conventional watch-only model and should be treated as a privileged secret boundary.
2. Silent Payment send correctness depends on the offline signer's input-key normalization rules, especially when transaction inputs mix standard BIP86 Taproot and SP-derived Taproot UTXOs.
3. The signer has multiple fallback key-discovery paths. Those improve recoverability, but they can also hide metadata or derivation bugs unless the negative tests are strong.
4. The online side currently constructs normal change using `get_change_address("bip84")` in the default send flow. That behavior is not automatically wrong, but it means change-type policy is currently fixed and should be treated as part of the wallet's fund-recovery contract.
5. Silent Payment detection and spendability depend on persisted tweak metadata. The audit should verify that losing local online state does not make already-detected SP funds unrecoverable as long as the user still has the offline backup material and chain data.

## 12. Immediate follow-up tasks

1. prove every row in this matrix with tests or external vectors
2. verify descriptor origin fields against Bitcoin Core expectations
3. verify BIP86 and SP Taproot tweak rules with explicit cross-check tests
4. verify SP state recovery assumptions from backup-only scenarios

