Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/developer/adr/0012-wallet-level-watch-only-uniformity.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ The legacy JSON-RPC contract (`getbalance`, `listaccounts`,
external consumers do not break. Wallet-internal callers migrate to the
single-bucket shape.

### Supplied account XPub custody

SQL accepts arbitrary numberless and path-bound external account XPub material
only in a uniformly watch-only wallet. A spendable SQL wallet rejects either
public-only account shape because an XPub does not provide signing authority.

Importing a private wallet root is different from supplying an account XPub.
Accounts derived from that root remain wallet-root-derived and may be
spendable.

The legacy kvdb mixed-mode exception remains limited to arbitrary, numberless,
watch-chain account imports; it does not provide path-bound identity or SQL
parity. ADR 0013 owns the account identity decisions.

### Imported addresses land in a reserved wallet-level bucket

An imported address — with or without private-key material — is never
Expand Down Expand Up @@ -193,6 +207,9 @@ model.
- [ADR 0011](0011-no-addresses-used-column.md): addresses table omits the
`used` column (related — also a "compute, don't persist" choice; this ADR
makes the opposite call by persisting wallet-level watch-only).
- [ADR 0013](0013-normalized-account-address-identity.md): defines account
provenance, optional number and fingerprint, scope, schema, and discovery
identity independently of wallet custody.
- [Bitcoin Core v23.0 release notes][btc-core-v23-release]: documents
descriptor wallets becoming the default wallet type for new wallets.
- [Bitcoin Core `createwallet` documentation][btc-core-createwallet]:
Expand Down
202 changes: 142 additions & 60 deletions docs/developer/adr/0013-normalized-account-address-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,54 +20,131 @@ looked like members of an account solely because SQL needed a non-null

The recovery follow-up needs immutable SQL identity for imported xpub scan
horizons. Account names and nullable account numbers are not suitable for that
purpose: names can be renamed, and imported xpubs do not have BIP44 account
numbers.
purpose: names can be renamed, and arbitrary imported XPub accounts do not
have account numbers.

The word "imported" is overloaded across wallet layers. A wallet may be
imported from a seed while its accounts remain derivable from that seed. An
account xpub may be imported while its child addresses remain derivable from the
xpub. A raw script or public-key address import is not derivable at all. The SQL
schema therefore models whether a row has derivation identity, not where the
user originally obtained the material.
account XPub may be imported while its child addresses remain derivable from
the XPub. A raw script or public-key address import is not derivable at all.
The SQL schema therefore keeps account-key provenance separate from whether an
address row has derivation identity.

## 2. Decision

Normalize account and address identity around real persisted identity only:

| Case | Parent | Number | Path |
| Case | Parent | Number | Child path |
| --- | --- | --- | --- |
| Wallet-derived account | `accounts` row | BIP44 number | N/A |
| Imported xpub account | `accounts` row | none | N/A |
| Wallet-derived child address | derived account | has number | branch/index |
| Imported-xpub child address | imported xpub account | none | branch/index |
| Wallet-root-derived account | `accounts` row | required | N/A |
| Arbitrary external XPub account | `accounts` row | none | N/A |
| Path-bound external XPub account | `accounts` row | required | N/A |
| Root-derived child address | root account | required | branch/index |
| Arbitrary-XPub child address | external account | none | branch/index |
| Path-bound-XPub child address | external account | required | branch/index |
| Raw imported address | none | none | none; no scope |

The SQL primitive is `is_derived` rather than `is_imported`. An imported-xpub
child address is imported from a user perspective, but it is still derived from
an account xpub and has branch/index path facts. A raw imported address is not
derived from an account and has no scope or path facts.

The structural booleans describe row shape only. They are not provenance or
audit fields. If provenance becomes necessary later, it should be modeled
separately.
The address `is_derived` boolean describes row shape only. Account
`is_derived` has the narrower meaning "the account XPub was derived from this
wallet's private root." It is an immutable custody/provenance fact and does not
say whether child addresses can be derived: all three account shapes derive
branch and address children from an account XPub.

### Accounts

`accounts` is the stable account identity table. It holds wallet, scope, name,
account-level public key, master fingerprint, next external/internal derivation
indexes, a structural `is_derived` bit, and a nullable `account_number`.

Wallet-derived accounts set `is_derived` and have a non-null BIP44 account
number. Imported xpub accounts clear `is_derived` and leave `account_number`
NULL because they do not have wallet-derived BIP44 identity.

There is no `derived_accounts` table. The account ID is the immutable identity
for both wallet-derived accounts and imported xpub accounts. Account number is
an optional identity attribute of wallet-derived accounts only.

Account identity fields, including `id`, `wallet_id`, `scope_id`,
`is_derived`, and `account_number`, are immutable after creation. Account-name
uniqueness stays centralized on `accounts`.
indexes, an `is_derived` provenance bit, and a nullable `account_number`.

Wallet-root-derived accounts set `is_derived` and have a non-null account
number. Arbitrary external XPub accounts clear `is_derived` and leave
`account_number` NULL. Path-bound external XPub accounts also clear
`is_derived`, because the wallet did not derive their account key, but retain
the real non-null hardened account component declared by the caller and
validated against the supplied depth-three XPub.

There is no `derived_accounts` table. The account ID remains an internal Store
identity for all account shapes. It is not a portable public identifier.
Numbered public lookup uses `(scope, account_number)` for either root-derived
or path-bound external provenance. An arbitrary external account has no number
and is selected by its mutable name within its scope.

Account provenance, account-number presence and value, master-fingerprint
presence and value, scope, effective address schema, discovery mode, and XPub
are immutable after creation. Account names remain mutable, scope-unique
display and lookup metadata. SQL row IDs and kvdb adapter account numbers never
cross the Store boundary as semantic identity.

#### Account Creation Identity

Wallet-root provenance and account-number presence are orthogonal. The
accepted account shapes are:

| Source | SQL custody | Provenance | Number | Fingerprint |
| --- | --- | --- | --- | --- |
| Generated seed | spendable | wallet-root | required | computed |
| Imported private root | spendable | wallet-root | required | computed |
| Arbitrary account XPub | watch-only | external arbitrary | absent | optional |
| Depth-three XPub | watch-only | external path-bound | required | optional |

The persisted `is_derived` fact and the public `IsImported` projection encode
wallet-root versus external provenance. The optional account number
distinguishes arbitrary from path-bound external identity, so no second
provenance enum is needed. Accounts derived from an imported private wallet
root remain wallet-root-derived; a supplied account XPub remains external even
when it has a known account number.

For a path-bound external XPub, the full key origin is the caller-declared
tuple `(master fingerprint?, purpose', coin_type', account')`. The wallet
validates the public key's network, depth, hardened account child, and
consistency with the declared path. It cannot authenticate the hardened
ancestors or optional fingerprint, which remain caller-declared provenance.
Fingerprint presence, including present-zero, is preserved. Root-derived
accounts use the wallet-computed fingerprint and treat a supplied value as an
equality assertion; external accounts retain the optional declared value.

#### Account Creation Requests

`NewAccount` carries name, scope, optional address schema, optional exact
account number, optional account XPub, optional master fingerprint, and
discovery mode. Without an XPub it creates a sequential or exact root-derived
account. An XPub and exact number together create a path-bound external
account; an XPub without a number is invalid. No separate
`EnsureExactAccount` operation is introduced.

`ImportAccount` carries name, scope, optional address schema, account XPub,
optional master fingerprint, discovery mode, and dry-run. It creates only an
arbitrary numberless external account, and dry-run performs the same validation
without persistence.

The caller-declared scope is authoritative for every supplied-XPub request.
Extended-key version bytes are checked only for the configured Bitcoin
network and do not infer scope or schema. Discovery mode is explicit and
immutable.

#### Scope And Branch Schema

An omitted address schema reuses an existing scope's persisted schema or the
canonical schema for a standard scope. Creating a new custom scope requires an
explicit schema. Supplying a schema for an existing scope is an equality
assertion; any external/internal branch mismatch is a conflict and the request
fails before mutation.

Each branch must be derivable from a single account key. The supported forms
are P2PKH (`PubKeyHash`), P2SH-P2WPKH (`NestedWitnessPubKey`), P2WPKH
(`WitnessPubKey`), and key-path P2TR (`TaprootPubKey`); other forms are not
account schemas.

Equal normalized account-XPub payloads are admitted within or across scopes
only when both accounts' effective branch schemas derive disjoint scripts.

Existing schema fields, including `coin_pub_key`, `key_scope_secrets`, and all
other columns, remain; this ADR prescribes no migration or source change.

#### Store Adapter Identity

Expand All @@ -82,18 +159,18 @@ kvdb account numbers restart in every key scope, so an `AccountID` can recur
within one wallet. SQL row IDs happen to have wider uniqueness, but shared code
must not depend on that backend-specific property.

kvdb populates the adapter identity for wallet-derived accounts, imported-xpub
kvdb populates the adapter identity for wallet-derived accounts, imported-XPub
accounts, and its imported-address pseudo-account. An imported account has no
wallet-derived BIP44 `AccountNumber`, but waddrmgr still persists an unmasked
internal account number for scoped lookup. Recovery also uses the adapter
identity to key per-account state and stamp scan horizons. Returning no kvdb
adapter identity would therefore make imported-account recovery and numeric
kvdb lookup ambiguous or unusable.
public `AccountNumber` under the legacy ordinary-import contract, but waddrmgr
still persists an unmasked internal account number for scoped lookup. Recovery
also uses the adapter identity to key per-account state and stamp scan
horizons. Returning no kvdb adapter identity would therefore make imported-
account recovery and numeric kvdb lookup ambiguous or unusable.

`AccountNumber` remains an optional BIP44 derivation attribute, and
`AccountNumber` remains an optional known hardened account component, and
`AccountName` remains mutable display and lookup metadata. A wallet-derived
kvdb account's adapter ID may numerically equal its BIP44 account number, but
that coincidence does not give the two fields the same meaning.
kvdb account's adapter ID may numerically equal its account number, but that
coincidence does not give the two fields the same meaning or prove provenance.

The adapter identity is stable only for an account within its backing Store
and backend-defined domain. SQL row IDs and kvdb account numbers are not
Expand All @@ -105,36 +182,37 @@ backend's adapter value, an SQL row ID, or the current

#### Account Alternatives

The first rejected alternative was to keep mapping imported xpub accounts to
account number `0`. That preserves compatibility with BIP44-shaped callers, but
it makes imported xpubs collide with the default wallet-derived account and
forces runtime code to distinguish fake `0` from real `0`.
The first rejected alternative was to keep mapping arbitrary imported XPub
accounts to account number `0`. That preserves compatibility with BIP44-shaped
callers, but it makes arbitrary imports collide with the default wallet-
derived account and forces runtime code to distinguish fake `0` from real `0`.

The second rejected alternative was to split BIP44 account numbers into a
`derived_accounts` table. That matched the subtype model, but it mostly moved
one nullable field out of `accounts` while adding another join and a
parent/child shape invariant. A row-local check on `accounts` is simpler and
still prevents imported xpub accounts from being mistaken for BIP44 account
`0`.
still prevents arbitrary imported XPub accounts from being mistaken for
account `0`.

The accepted tradeoff is a nullable `account_number` with `is_derived` enforcing
the row shape. This keeps the account invariant local to the account row, avoids
parent/child drift for one optional field, and still removes fake identity.
The accepted tradeoff is a nullable `account_number` interpreted together with
immutable account provenance. This keeps the invariant local to the account
row, admits a truthful numbered external path, and still removes fake identity.

#### Account Consequences

Pros:

- Imported xpub accounts can no longer be mistaken for BIP44 account `0`.
- `GetAccountByNumber` is derived-account-only by construction.
- SQL recovery can later key imported-xpub scan horizons by immutable
`account_id`.
- Arbitrary imported XPub accounts can no longer be mistaken for account `0`.
- Numbered lookup can represent both root-derived and path-bound external
accounts without accepting a backend ID.
- SQL recovery can key arbitrary-XPub scan horizons by immutable internal
`account_id` while public code uses semantic selectors.
- Account lists remain low-cardinality reads over one identity table.

Cons:

- Go callers must treat account numbers as optional.
- Imported account code must not collapse SQL NULL to Go zero.
- Arbitrary external-account code must not collapse SQL NULL to Go zero.
- Read paths must consistently reject impossible account shapes.

### Addresses
Expand Down Expand Up @@ -189,8 +267,8 @@ Pros:

- Raw imported addresses no longer require a fake account row.
- Raw imports no longer carry fake scope or derivation-path identity.
- Imported-xpub child addresses and wallet-derived child addresses share the
same derived-address path model.
- Arbitrary-XPub, path-bound-XPub, and wallet-root-derived child addresses
share the same derived-address path model.
- Derived address queries can start from account/path facts, while raw import
queries can start from wallet-local script identity.
- Account-scoped address creation and address-index checks can use
Expand All @@ -215,16 +293,20 @@ Cons:
types.
- `db.AccountInfo` exposes an internal adapter `AccountID` and makes
`AccountNumber` optional. SQL maps `AccountID` from `accounts.id`; kvdb maps
it from waddrmgr's per-scope internal account number. Shared code pairs it
with `KeyScope` and must not expose it as a portable public account ID.
it from waddrmgr's per-scope internal account number. SQL account numbers are
present for root-derived and path-bound external accounts and absent for
arbitrary external accounts. Shared code pairs adapter identity with
`KeyScope` and must not expose it as a portable public account ID.
- `db.AddressInfo` makes `AccountID` and `AccountNumber` optional. SQL raw
imports have neither, use an empty account name and zero key scope, and are
listed with an accountless query. Imported-xpub child addresses have an
account ID and scope inherited from their account but no BIP44 account
number.
- `AddressDerivationParams` carries an optional derived account number. Imported
xpub child addresses must not synthesize `0` and accidentally derive wallet
seed keys.
listed with an accountless query. Arbitrary-XPub child addresses have an
account ID and scope inherited from their account but no account number;
path-bound-XPub children retain their real number.
- `AddressDerivationParams` carries an optional account number. Arbitrary-XPub
child addresses must not synthesize `0` and accidentally claim a full public
origin or derive wallet-seed keys. A path-bound external child may report its
caller-declared origin without claiming the wallet authenticated its hardened
ancestors.
- `ListAddressesQuery` uses pointer selectors: both `Scope` and `AccountName`
set means account-scoped derived children; both nil means raw imported
addresses; one set without the other is invalid.
Expand Down
Loading