Summary
key-wallet has no wallet-state-agnostic entry points for deriving provider keys (operator BLS, platform node Ed25519) at a given index. The existing convenience wrappers are gated on is_watch_only in opposite directions, so no single upstream API works for both resident and restored/watch-only wallets — forcing downstream consumers to re-compose derivation from low-level primitives, which reintroduces the exact duplication that caused dashpay/platform's stale-derivation bug (see #878 / #879 and dashpay/platform#4120).
The gating asymmetry (the concrete deficiency)
BLSAccount::derive_bls_key_at_path / derive_bls_key_at_index (public derivation) require is_watch_only == true — on a resident account they fail with InvalidParameter("Cannot derive keys from BLS account without private key access") (bls_account.rs:154-159).
derive_from_seed_extended_xpriv_at / derive_from_seed_private_key_at (seed derivation) route through derive_xpriv_from_master_xpriv, which requires is_watch_only == false — on a restored/external-signable account they fail with Error::WatchOnly (bls_account.rs:274, eddsa_account.rs:269).
A resident (Wallet::from_mnemonic) wallet's provider account is non-watch-only; a restored external-signable wallet's account is watch-only (BLSAccount::new / EdDSAAccount::new set is_watch_only: true). Consumers that must serve both wallet states (mobile wallets restore as external-signable; tests/tools build resident wallets) can use neither wrapper universally.
What downstream is forced to do today
dashpay/platform's rs-platform-wallet now composes the gate-free low-level primitives directly (after dashpay/platform#4120):
- operator public:
account.bls_public_key.derive_pub_legacy(child)
- operator private:
ExtendedBLSPrivKey::new_master(network, seed).derive_path_legacy(account_path).derive_priv_legacy(child)
- platform node:
ExtendedEd25519PrivKey::new_master(network, seed).derive_priv(account_path).derive_priv([hardened child])
This composition encodes protocol knowledge that belongs next to the derivation itself: the legacy-scheme choice for BLS HD, the hardened-leaf convention for Ed25519, and the account-path re-application from the raw BIP39 seed. Every consumer (platform, the Kotlin SDK, future bindings) has to rediscover and maintain the same sequence — and history shows what happens when it drifts: platform carried a stale pre-#879 duplicate that kept producing wrong keys after the upstream fix landed.
Proposed API
Gate-free, wallet-state-agnostic entry points on the account types (names indicative):
impl BLSAccount {
/// Public-only derivation off the account xpub (legacy scheme).
/// Works on watch-only AND resident accounts.
pub fn operator_public_key_at(&self, index: u32) -> Result<PublicKey, Error>;
/// Private derivation from the raw BIP39 seed (legacy scheme, full
/// account path re-derived). Works regardless of watch-only status.
pub fn operator_private_key_at(seed: &[u8], network: Network, index: u32) -> Result<SecretKey, Error>;
}
impl EdDSAAccount {
/// SLIP-10 hardened derivation from the raw BIP39 seed.
pub fn platform_node_key_at(seed: &[u8], network: Network, index: u32) -> Result<SigningKey, Error>;
}
(Or equivalent free functions / Wallet-level helpers — the key properties are: no is_watch_only gate, raw-seed input for private paths, xpub input for public paths, and both BLS serializations obtainable from the result.)
Golden vectors already exist in key-wallet/src/tests/provider_key_derivation_tests.rs — the new entry points should be pinned to them.
Impact
- dashpay/platform can shrink
rs-platform-wallet/src/wallet/provider_key_at_index.rs to a pure adapter (wallet lookup + FFI type mapping), eliminating the duplicated-derivation bug class permanently.
- Kotlin/other bindings get a single audited entry point instead of re-composing primitives.
Related: #878 (derivation divergence), #879 (the fix), #876 (provider payload surfacing), dashpay/platform#4120 (downstream consumption + the composition this issue proposes to absorb).
Summary
key-wallet has no wallet-state-agnostic entry points for deriving provider keys (operator BLS, platform node Ed25519) at a given index. The existing convenience wrappers are gated on
is_watch_onlyin opposite directions, so no single upstream API works for both resident and restored/watch-only wallets — forcing downstream consumers to re-compose derivation from low-level primitives, which reintroduces the exact duplication that caused dashpay/platform's stale-derivation bug (see #878 / #879 and dashpay/platform#4120).The gating asymmetry (the concrete deficiency)
BLSAccount::derive_bls_key_at_path/derive_bls_key_at_index(public derivation) requireis_watch_only == true— on a resident account they fail withInvalidParameter("Cannot derive keys from BLS account without private key access")(bls_account.rs:154-159).derive_from_seed_extended_xpriv_at/derive_from_seed_private_key_at(seed derivation) route throughderive_xpriv_from_master_xpriv, which requiresis_watch_only == false— on a restored/external-signable account they fail withError::WatchOnly(bls_account.rs:274,eddsa_account.rs:269).A resident (
Wallet::from_mnemonic) wallet's provider account is non-watch-only; a restored external-signable wallet's account is watch-only (BLSAccount::new/EdDSAAccount::newsetis_watch_only: true). Consumers that must serve both wallet states (mobile wallets restore as external-signable; tests/tools build resident wallets) can use neither wrapper universally.What downstream is forced to do today
dashpay/platform's
rs-platform-walletnow composes the gate-free low-level primitives directly (after dashpay/platform#4120):account.bls_public_key.derive_pub_legacy(child)ExtendedBLSPrivKey::new_master(network, seed).derive_path_legacy(account_path).derive_priv_legacy(child)ExtendedEd25519PrivKey::new_master(network, seed).derive_priv(account_path).derive_priv([hardened child])This composition encodes protocol knowledge that belongs next to the derivation itself: the legacy-scheme choice for BLS HD, the hardened-leaf convention for Ed25519, and the account-path re-application from the raw BIP39 seed. Every consumer (platform, the Kotlin SDK, future bindings) has to rediscover and maintain the same sequence — and history shows what happens when it drifts: platform carried a stale pre-#879 duplicate that kept producing wrong keys after the upstream fix landed.
Proposed API
Gate-free, wallet-state-agnostic entry points on the account types (names indicative):
(Or equivalent free functions /
Wallet-level helpers — the key properties are: nois_watch_onlygate, raw-seed input for private paths, xpub input for public paths, and both BLS serializations obtainable from the result.)Golden vectors already exist in
key-wallet/src/tests/provider_key_derivation_tests.rs— the new entry points should be pinned to them.Impact
rs-platform-wallet/src/wallet/provider_key_at_index.rsto a pure adapter (wallet lookup + FFI type mapping), eliminating the duplicated-derivation bug class permanently.Related: #878 (derivation divergence), #879 (the fix), #876 (provider payload surfacing), dashpay/platform#4120 (downstream consumption + the composition this issue proposes to absorb).