@@ -459,23 +459,41 @@ func convertAddressMetadata[TypeID, OriginIDType any](
459459}
460460
461461// convertAddressPath converts BIP44 branch/index values into uint32 fields.
462- // Imported addresses must have both branch/index unset and return zero values.
463- // Derived addresses must have both fields set and convertible to uint32.
462+ //
463+ // The both-or-neither invariant holds for every origin: a row must carry both
464+ // branch and index or neither, and exactly one set is a corrupt path. Beyond
465+ // that, the meaning of an unset path depends on the origin:
466+ //
467+ // - A derived account is always HD, so both fields must be set.
468+ // - An imported account spans two shapes. A raw/bucket import (a single
469+ // pubkey or script with no chain position) carries neither field and maps
470+ // to zero values. An imported-xpub (watch-only xpub) account is HD: the
471+ // scan-batch horizon extension derives child addresses from its account
472+ // xpub and persists them with a real branch/index, which the schema
473+ // permits (only the both-or-neither and range constraints apply). Those
474+ // rows must read back with their derivation path, not be rejected, so they
475+ // surface through IterAddresses/ListAddresses like any HD address.
464476func convertAddressPath (origin AccountOrigin , branch ,
465477 index sql.NullInt64 ) (uint32 , uint32 , error ) {
466478
467- if origin == ImportedAccount {
468- if branch .Valid || index .Valid {
469- return 0 , 0 , errInvalidDerivationPath
470- }
471-
472- return 0 , 0 , nil
479+ // Reject a half-populated path for every origin; the remaining branches
480+ // only have to distinguish "both unset" from "both set".
481+ if branch .Valid != index .Valid {
482+ return 0 , 0 , errInvalidDerivationPath
473483 }
474484
475- if ! branch .Valid || ! index .Valid {
485+ // A raw/bucket imported address carries no chain position and reads back
486+ // as zero values; a derived account must always have a path set.
487+ if ! branch .Valid {
488+ if origin == ImportedAccount {
489+ return 0 , 0 , nil
490+ }
491+
476492 return 0 , 0 , errInvalidDerivationPath
477493 }
478494
495+ // Both fields are set: a derived address or an imported-xpub HD address,
496+ // converted the same way.
479497 addrBranch , err := Int64ToUint32 (branch .Int64 )
480498 if err != nil {
481499 return 0 , 0 , fmt .Errorf ("address branch: %w" , err )
@@ -520,6 +538,11 @@ func AddressRowToInfo[TypeID, OriginIDType any](
520538 return nil , err
521539 }
522540
541+ // A row carries an HD path iff both nullable path columns are present.
542+ // convertAddressPath has already rejected a half-populated path, so the
543+ // two Valid flags agree here; checking both keeps the intent explicit.
544+ hasDerivationPath := row .AddressBranch .Valid && row .AddressIndex .Valid
545+
523546 return & AddressInfo {
524547 ID : id ,
525548 AccountID : accountID ,
@@ -532,6 +555,7 @@ func AddressRowToInfo[TypeID, OriginIDType any](
532555 Origin : origin ,
533556 Branch : addrBranch ,
534557 Index : addrIndex ,
558+ HasDerivationPath : hasDerivationPath ,
535559 ScriptPubKey : row .ScriptPubKey ,
536560 PubKey : row .PubKey ,
537561 HasScript : row .HasScript ,
@@ -761,16 +785,17 @@ func createDerivedAddress[T any](ctx context.Context,
761785 }
762786
763787 return & AddressInfo {
764- ID : id ,
765- AccountID : convertedAcctID ,
766- AddrType : addrType ,
767- CreatedAt : rowCreatedAt (row ),
768- Origin : DerivedAccount ,
769- Branch : branch ,
770- Index : index ,
771- ScriptPubKey : scriptPubKey ,
772- PubKey : pubKey ,
773- IsWatchOnly : walletIsWatchOnly ,
788+ ID : id ,
789+ AccountID : convertedAcctID ,
790+ AddrType : addrType ,
791+ CreatedAt : rowCreatedAt (row ),
792+ Origin : DerivedAccount ,
793+ Branch : branch ,
794+ Index : index ,
795+ HasDerivationPath : true ,
796+ ScriptPubKey : scriptPubKey ,
797+ PubKey : pubKey ,
798+ IsWatchOnly : walletIsWatchOnly ,
774799 }, nil
775800}
776801
0 commit comments