@@ -3000,20 +3000,38 @@ unsafe fn address_info_from_ffi(
30003000}
30013001
30023002/// Restore persisted `AddressInfo` rows into a managed account's
3003- /// `AddressPool`. Plain upsert : a persisted row overwrites the gap-limit
3003+ /// `AddressPool`. Upsert : a persisted row overwrites the gap-limit
30043004/// default `ManagedWalletInfo::from_wallet` pre-derived at the same
30053005/// index, and the reverse-lookup maps + `highest_*` watermarks are
30063006/// extended to cover indices past that default gap window.
30073007///
30083008/// The persisted row is authoritative for its typed public key — the
3009- /// [`CoreAddressEntryFFI`] row now carries the full typed key (ECDSA-33 /
3009+ /// [`CoreAddressEntryFFI`] row carries the full typed key (ECDSA-33 /
30103010/// BLS-48 / EdDSA-32) with a [`KeyTypeTagFFI`] discriminator, so BLS
30113011/// operator and Ed25519 platform-node keys survive the round-trip in the
3012- /// row itself. No merge against the pre-derived entry is needed or
3013- /// wanted.
3012+ /// row itself — with ONE legacy exception: rows persisted before the
3013+ /// typed-key column existed carry an empty key (`public_key: None`).
3014+ /// Overwriting a pre-derived typed entry with such a row would strip the
3015+ /// in-memory BLS operator pubkeys that `from_wallet` derived from the
3016+ /// account xpub, silently breaking operator-ownership matching for every
3017+ /// pre-typed-key store. So when the incoming row has no key but the
3018+ /// pre-derived entry at that index does, the existing typed key is kept
3019+ /// (post-migration rows always carry their key, making this a no-op for
3020+ /// them). Legacy Ed25519 platform-node rows cannot be recovered this way
3021+ /// (hardened-only — no public derivation) nor migrated from the removed
3022+ /// account-level batch (its data was dropped by the schema migration);
3023+ /// per the pre-release convention those stores re-derive on
3024+ /// delete+re-import.
30143025fn restore_address_pool ( pool : & mut AddressPool , infos : Vec < AddressInfo > ) {
3015- for info in infos {
3026+ for mut info in infos {
30163027 let idx = info. index ;
3028+ if info. public_key . is_none ( ) {
3029+ if let Some ( existing) = pool. addresses . get ( & idx) {
3030+ if existing. public_key . is_some ( ) {
3031+ info. public_key = existing. public_key . clone ( ) ;
3032+ }
3033+ }
3034+ }
30173035 pool. address_index . insert ( info. address . clone ( ) , idx) ;
30183036 pool. script_pubkey_index
30193037 . insert ( info. script_pubkey . clone ( ) , idx) ;
@@ -5920,6 +5938,57 @@ mod tests {
59205938 }
59215939 }
59225940
5941+ /// A LEGACY row (persisted before the typed-key column: empty key,
5942+ /// `public_key: None` after decode) must NOT strip the typed key the
5943+ /// gap-limit prederivation put at the same index — pre-typed-key
5944+ /// stores otherwise lose their in-memory BLS operator pubkeys at
5945+ /// load and masternode operator-ownership matching silently breaks
5946+ /// (post-migration rows always carry their key, so the preservation
5947+ /// is a no-op for them). Also pins the inverse: a legacy row at an
5948+ /// index with no prederived entry restores key-less rather than
5949+ /// inventing anything.
5950+ #[ test]
5951+ fn legacy_keyless_row_keeps_prederived_typed_key ( ) {
5952+ let mut pool = AddressPool :: new_without_generation (
5953+ DerivationPath :: from_str ( "m/9'/1'/3'" ) . expect ( "static path must parse" ) ,
5954+ AddressPoolType :: AbsentHardened ,
5955+ 5 ,
5956+ Network :: Testnet ,
5957+ ) ;
5958+
5959+ // Prederived typed entry, as `ManagedWalletInfo::from_wallet`
5960+ // seeds BLS operator pools from the account xpub.
5961+ let bls = vec ! [ 0xE7u8 ; 48 ] ;
5962+ let prederived = typed_key_test_address_info ( 7 , Some ( PublicKeyType :: BLS ( bls. clone ( ) ) ) ) ;
5963+ pool. addresses . insert ( 7 , prederived) ;
5964+
5965+ // Legacy rows: same index key-less, plus one at a fresh index.
5966+ let legacy_same_idx = typed_key_test_address_info ( 7 , None ) ;
5967+ let legacy_fresh_idx = typed_key_test_address_info ( 9 , None ) ;
5968+ restore_address_pool ( & mut pool, vec ! [ legacy_same_idx, legacy_fresh_idx] ) ;
5969+
5970+ match & pool
5971+ . addresses
5972+ . get ( & 7 )
5973+ . expect ( "entry 7 must exist" )
5974+ . public_key
5975+ {
5976+ Some ( PublicKeyType :: BLS ( bytes) ) => assert_eq ! (
5977+ bytes, & bls,
5978+ "legacy key-less row must keep the prederived BLS key"
5979+ ) ,
5980+ other => panic ! ( "prederived BLS key was stripped, got {:?}" , other) ,
5981+ }
5982+ assert ! (
5983+ pool. addresses
5984+ . get( & 9 )
5985+ . expect( "entry 9 must exist" )
5986+ . public_key
5987+ . is_none( ) ,
5988+ "a legacy row with no prederived counterpart stays key-less"
5989+ ) ;
5990+ }
5991+
59235992 /// `account_xpub` must survive the persist→restore byte round-trip — it is
59245993 /// the key the seed-binding self-check (`PlatformWallet::verify_seed_binds`)
59255994 /// later compares the resolver-derived xpub against, so a corrupted restore
0 commit comments