diff --git a/docs/developer/adr/0011-no-addresses-used-column.md b/docs/developer/adr/0011-no-addresses-used-column.md new file mode 100644 index 0000000000..fa02e1827b --- /dev/null +++ b/docs/developer/adr/0011-no-addresses-used-column.md @@ -0,0 +1,84 @@ +# ADR 0011: No `used` Column on the Addresses Table + +## 1. Context + +The wallet needs to answer whether an address has appeared in any transaction +the wallet has seen. This is a monotonic property used by unused-address scans +to avoid re-offering a previously published address. + +The SQL transaction schema keeps observed wallet history as the source of +truth. A transaction that is disconnected from the best chain remains in the +`transactions` table with updated status and block metadata, and `utxos` rows +reference their creating transaction with restrictive foreign keys. The wallet +does not physically remove these rows during normal reorg, replace, or orphan +handling. + +Because observed credits remain represented in SQL history, address used-ness +can be derived from transaction state: + +```sql +SELECT EXISTS(SELECT 1 FROM utxos WHERE address_id = ?) +``` + +Storing a second `addresses.used` flag would duplicate the same fact and create +drift risk between address metadata and wallet history. + +### Scope + +The SQL `is_used` projection is monotonic for non-abandoned wallet history. +Explicit abandon/delete flows intentionally remove the abandoned transaction +state, matching the rest of the wallet's abandon semantics: balances revert and +the corresponding change indices may become reusable. + +## 2. Decision + +The SQL backends (`pg` and `sqlite`) do not persist a `used` column on the +`addresses` table. + +- SQL address-read queries project `db.AddressInfo.IsUsed` from `EXISTS` over + `utxos`. +- The Store interface intentionally has no SQL `MarkAddressUsed` method: + recording an observed wallet transaction inserts the `utxos` row that future + address reads consult via the `EXISTS` projection. +- The kvdb backend continues to populate `IsUsed` from waddrmgr's sticky used + bit, because legacy rollback handling does not provide the same durable SQL + history table. + +The `db.AddressInfo.IsUsed` contract remains backend-neutral. Callers see the +same logical wallet property even though SQL derives it from wallet history and +kvdb reads it from legacy address metadata. + +## 3. Consequences + +### Pros + +- SQL has one source of truth for observed address usage. +- Address metadata avoids an extra column, migration, trigger, and write path. +- Wallet code can continue to call the Store contract without knowing how each + backend materializes `IsUsed`. + +### Cons + +- SQL address reads pay an `EXISTS` lookup against `utxos`. The + `idx_utxos_by_address` index bounds that cost for single-address reads and + address-list scans. +- The SQL and kvdb adapters implement the same contract differently. Store + method comments and schema comments should point to this ADR so the asymmetry + remains intentional and discoverable. + +### Orthogonal: the unbroadcast-tx gap + +Neither a derived SQL projection nor a stored flag covers a transaction the +user constructs but never records or broadcasts. If no wallet transaction state +exists, the wallet has no durable fact proving the address was published. That +privacy gap is independent of where used-ness is materialized. + +## 4. Implementation Notes + +- SQL migrations do not add an `addresses.used` column. +- SQL address-read queries project `is_used` with `EXISTS` over `utxos` instead + of reading address metadata. +- The Store interface has no `MarkAddressUsed` method on SQL backends; SQL + derives used-ness from wallet transaction state recorded in the `utxos` + table. +- The kvdb adapter keeps using waddrmgr's used bit. diff --git a/docs/developer/adr/README.md b/docs/developer/adr/README.md index 3b569e8123..a15041a2ea 100644 --- a/docs/developer/adr/README.md +++ b/docs/developer/adr/README.md @@ -16,3 +16,4 @@ ADRs serve as a historical log of important design choices, providing context fo - [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) - Defines a modular integration test framework for chain and database backend permutations. - [ADR 0009: Single-Passphrase Encryption Model](./0009-single-passphrase-encryption.md) - Adopts a single-passphrase model that encrypts private data only while keeping public wallet metadata in plaintext. - [ADR 0010: Keyvault Encryption Layer](./0010-keyvault-encryption-layer.md) - Defines an in-memory keyvault boundary for lock state, key lifecycle, and encryption orchestration between domain logic and SQL persistence. +- [ADR 0011: No `used` Column on the Addresses Table](./0011-no-addresses-used-column.md) - Records the decision that the SQL backend derives address used-ness from the utxos table (monotonic by ADR 0006's soft-delete schema) rather than persisting a separate column. The kvdb backend continues to use waddrmgr's legacy sticky-bit because wtxmgr deletes credit records on reorg. diff --git a/waddrmgr/address.go b/waddrmgr/address.go index 96fbc0128c..7d3dea144b 100644 --- a/waddrmgr/address.go +++ b/waddrmgr/address.go @@ -39,6 +39,12 @@ var ( // ErrUnknownSigningMethod is returned when an address type reports a // signing method the current validation path does not understand. ErrUnknownSigningMethod = errors.New("unknown signing method") + + // errUnsupportedManagedPubKeyAddress is returned when a public-key + // address implementation cannot expose encrypted private-key state. + errUnsupportedManagedPubKeyAddress = errors.New( + "unsupported managed pubkey address", + ) ) // AddressType represents the various address types waddrmgr is currently able @@ -183,6 +189,19 @@ type ManagedPubKeyAddress interface { DerivationInfo() (KeyScope, DerivationPath, bool) } +// ManagedPubKeyAddressHasPrivateKey reports whether the managed public-key +// address carries encrypted private-key material. +func ManagedPubKeyAddressHasPrivateKey(addr ManagedPubKeyAddress) (bool, + error) { + + managedAddr, ok := addr.(*managedAddress) + if !ok { + return false, errUnsupportedManagedPubKeyAddress + } + + return len(managedAddr.privKeyEncrypted) > 0, nil +} + // ValidatableManagedAddress is a type of managed pubkey address that can // perform external validation to catch unintended mutations between the // derivation process and the ultimate address being created. This may help to diff --git a/waddrmgr/tlv.go b/waddrmgr/tlv.go index 0e238b4ec3..3f053f1a57 100644 --- a/waddrmgr/tlv.go +++ b/waddrmgr/tlv.go @@ -2,6 +2,7 @@ package waddrmgr import ( "bytes" + "errors" "fmt" "io" @@ -10,6 +11,18 @@ import ( "github.com/lightningnetwork/lnd/tlv" ) +var ( + // errNilTapscript is returned when a caller attempts to encode a nil + // taproot script. + errNilTapscript = errors.New("cannot encode nil script") + + // errMissingControlBlockInternalKey is returned when a taproot control + // block cannot be serialized because it has no internal key. + errMissingControlBlockInternalKey = errors.New( + "control block is missing internal key", + ) +) + const ( typeTapscriptType tlv.Type = 1 typeTapscriptControlBlock tlv.Type = 2 @@ -22,11 +35,11 @@ const ( typeTapLeafScript tlv.Type = 2 ) -// tlvEncodeTaprootScript encodes the given internal key and full set of taproot -// script leaves into a byte slice encoded as a TLV stream. -func tlvEncodeTaprootScript(s *Tapscript) ([]byte, error) { +// EncodeTaprootScript encodes the given taproot script data into a byte slice +// encoded as a TLV stream. +func EncodeTaprootScript(s *Tapscript) ([]byte, error) { if s == nil { - return nil, fmt.Errorf("cannot encode nil script") + return nil, errNilTapscript } typ := uint8(s.Type) @@ -34,20 +47,13 @@ func tlvEncodeTaprootScript(s *Tapscript) ([]byte, error) { tlv.MakePrimitiveRecord(typeTapscriptType, &typ), } - if s.ControlBlock != nil { - if s.ControlBlock.InternalKey == nil { - return nil, fmt.Errorf("control block is missing " + - "internal key") - } + controlBlockRecord, ok, err := taprootControlBlockRecord(s.ControlBlock) + if err != nil { + return nil, err + } - blockBytes, err := s.ControlBlock.ToBytes() - if err != nil { - return nil, fmt.Errorf("error encoding control block: "+ - "%w", err) - } - tlvRecords = append(tlvRecords, tlv.MakePrimitiveRecord( - typeTapscriptControlBlock, &blockBytes, - )) + if ok { + tlvRecords = append(tlvRecords, controlBlockRecord) } if len(s.Leaves) > 0 { @@ -91,6 +97,44 @@ func tlvEncodeTaprootScript(s *Tapscript) ([]byte, error) { return buf.Bytes(), nil } +// taprootControlBlockRecord encodes a taproot control block into its TLV +// record, if the script includes one. +func taprootControlBlockRecord(block *txscript.ControlBlock) (tlv.Record, + bool, error) { + + if block == nil { + return tlv.Record{}, false, nil + } + + if block.InternalKey == nil { + return tlv.Record{}, false, errMissingControlBlockInternalKey + } + + blockBytes, err := block.ToBytes() + if err != nil { + return tlv.Record{}, false, + fmt.Errorf("error encoding control block: %w", err) + } + + record := tlv.MakePrimitiveRecord( + typeTapscriptControlBlock, &blockBytes, + ) + + return record, true, nil +} + +// DecodeTaprootScript decodes the given byte slice as a TLV stream and returns +// the taproot script data it encodes. +func DecodeTaprootScript(tlvData []byte) (*Tapscript, error) { + return tlvDecodeTaprootTaprootScript(tlvData) +} + +// tlvEncodeTaprootScript encodes the given internal key and full set of taproot +// script leaves into a byte slice encoded as a TLV stream. +func tlvEncodeTaprootScript(s *Tapscript) ([]byte, error) { + return EncodeTaprootScript(s) +} + // tlvDecodeTaprootTaprootScript decodes the given byte slice as a TLV stream // and attempts to parse the taproot internal key and full set of leaves from // it. diff --git a/wallet/internal/db/accounts_common.go b/wallet/internal/db/accounts_common.go index e14fc74469..b850475ad7 100644 --- a/wallet/internal/db/accounts_common.go +++ b/wallet/internal/db/accounts_common.go @@ -367,6 +367,40 @@ func getKeyCounts(external, internal, imported int64) (uint32, uint32, return externalKeyCount, internalKeyCount, importedKeyCount, nil } +// DerivedAddressAccountNumber converts a derived account number from an +// account lookup row to the wallet-compatible uint32 account number. +func DerivedAddressAccountNumber(accountNumber sql.NullInt64) (uint32, + error) { + + if !accountNumber.Valid { + return 0, ErrNilDBAccountNumber + } + + return validateAccountNumber(accountNumber.Int64) +} + +// DerivedAddressAccountSchema builds the effective address schema from the +// key-scope address-type IDs materialized by an account lookup row. +func DerivedAddressAccountSchema[AddrTypeID ~int16 | ~int64]( + internalTypeID AddrTypeID, externalTypeID AddrTypeID) (ScopeAddrSchema, + error) { + + internalType, err := IDToAddressType(internalTypeID) + if err != nil { + return ScopeAddrSchema{}, fmt.Errorf("internal address type: %w", err) + } + + externalType, err := IDToAddressType(externalTypeID) + if err != nil { + return ScopeAddrSchema{}, fmt.Errorf("external address type: %w", err) + } + + return ScopeAddrSchema{ + InternalAddrType: internalType, + ExternalAddrType: externalType, + }, nil +} + // AccountPropsRowToInfo converts a database row containing full account // properties into an AccountInfo struct. The idToAddrType function is // used to convert the internal and external address type IDs to AddressType diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index 64826d8d26..460a5e9488 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -137,6 +137,16 @@ func (p NewImportedAddressParams) HasSecretMaterial() bool { return len(p.EncryptedPrivateKey) > 0 || len(p.EncryptedScript) > 0 } +// HasScript returns true if the params include script spend data. +func (p NewImportedAddressParams) HasScript() bool { + return len(p.EncryptedScript) > 0 +} + +// IsWatchOnly returns true if the params do not include private key material. +func (p NewImportedAddressParams) IsWatchOnly() bool { + return !p.HasPrivateKey() +} + // IDToOrigin safely converts an integer to AccountOrigin. It returns an error // if the value is outside [DerivedAccount, ImportedAccount]. func IDToOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { @@ -157,6 +167,26 @@ type AddressInfoRow[TypeID, OriginIDType any] struct { // AccountID is the database unique identifier for the account. AccountID int64 + // AccountNumber is the BIP44 account index of the owning account when the + // account is derived. Imported accounts leave this NULL. + AccountNumber sql.NullInt64 + + // AccountName is the human-readable name of the owning account. + AccountName string + + // MasterFingerprint is the root fingerprint stored on the owning account. + MasterFingerprint sql.NullInt64 + + // AccountProps contains account metadata fetched separately when the + // address query does not join all account fields. + AccountProps *AccountInfo + + // Purpose is the BIP43 purpose component of the owning scope. + Purpose int64 + + // CoinType is the BIP44 coin type component of the owning scope. + CoinType int64 + // TypeID is the database identifier for the address type. TypeID TypeID @@ -184,12 +214,16 @@ type AddressInfoRow[TypeID, OriginIDType any] struct { // addresses. AddressIndex sql.NullInt64 - // ScriptPubKey is the script pubkey. Zero value for derived addresses. + // ScriptPubKey is the script pubkey stored for the address. ScriptPubKey []byte - // PubKey is the public key. Zero value for derived addresses. + // PubKey is the public key when the address is public-key based. PubKey []byte + // IsUsed reports whether the address has a non-abandoned + // on-chain transaction the wallet has observed. See ADR 0011. + IsUsed bool + // IDToAddrType converts TypeID to AddressType with validation. IDToAddrType func(TypeID) (AddressType, error) @@ -249,6 +283,95 @@ func convertAddressIDs(id, accountID int64) (uint32, uint32, error) { return addrID, acctID, nil } +// convertAccountMetadata converts account-level row data into wallet-facing +// fields on AddressInfo. +func convertAccountMetadata(accountNumber sql.NullInt64, + masterFingerprint sql.NullInt64, purpose int64, coinType int64) (uint32, + uint32, KeyScope, error) { + + var account uint32 + if accountNumber.Valid { + converted, err := Int64ToUint32(accountNumber.Int64) + if err != nil { + return 0, 0, KeyScope{}, fmt.Errorf("account number: %w", err) + } + + account = converted + } + + var fingerprint uint32 + if masterFingerprint.Valid { + converted, err := Int64ToUint32(masterFingerprint.Int64) + if err != nil { + return 0, 0, KeyScope{}, + fmt.Errorf("master fingerprint: %w", err) + } + + fingerprint = converted + } + + convertedPurpose, err := Int64ToUint32(purpose) + if err != nil { + return 0, 0, KeyScope{}, fmt.Errorf("scope purpose: %w", err) + } + + convertedCoin, err := Int64ToUint32(coinType) + if err != nil { + return 0, 0, KeyScope{}, fmt.Errorf("scope coin type: %w", err) + } + + return account, fingerprint, KeyScope{ + Purpose: convertedPurpose, + Coin: convertedCoin, + }, nil +} + +// convertAddressAccountMetadata converts the owning account metadata for an +// address row. SQL backends may fetch account properties separately to avoid +// widening address queries. +func convertAddressAccountMetadata[TypeID, OriginIDType any]( + row AddressInfoRow[TypeID, OriginIDType]) (uint32, string, uint32, + KeyScope, error) { + + if row.AccountProps != nil { + return row.AccountProps.AccountNumber, row.AccountProps.AccountName, + row.AccountProps.MasterKeyFingerprint, + row.AccountProps.KeyScope, nil + } + + accountNumber, masterFingerprint, keyScope, err := + convertAccountMetadata( + row.AccountNumber, row.MasterFingerprint, row.Purpose, + row.CoinType, + ) + if err != nil { + return 0, "", 0, KeyScope{}, err + } + + return accountNumber, row.AccountName, masterFingerprint, keyScope, nil +} + +// ApplyAddressAccountMetadata converts and copies raw account metadata onto an +// address info returned by a create path. +func ApplyAddressAccountMetadata(info *AddressInfo, + accountNumber sql.NullInt64, accountName string, + masterFingerprint sql.NullInt64, purpose, coinType int64) error { + + acctNum, fingerprint, keyScope, err := convertAccountMetadata( + accountNumber, masterFingerprint, purpose, coinType, + ) + if err != nil { + return err + } + + info.AccountNumber = acctNum + info.AccountName = accountName + info.KeyScope = keyScope + info.MasterKeyFingerprint = fingerprint + + return nil +} + // newImportedAddressTx handles the shared transaction flow for creating an // imported address across database backends. It creates the address row and // conditionally inserts the address_secrets row when any encrypted private key @@ -290,6 +413,7 @@ func newImportedAddressTx[QTX any, Row any, CreateArgs any, InsertArgs any]( Origin: ImportedAccount, ScriptPubKey: params.ScriptPubKey, PubKey: params.PubKey, + HasScript: params.HasScript(), IsWatchOnly: importedAddressIsWatchOnly( walletIsWatchOnly, params.HasPrivateKey(), ), @@ -360,6 +484,12 @@ func AddressRowToInfo[TypeID, OriginIDType any]( return nil, err } + accountNumber, accountName, masterFingerprint, keyScope, err := + convertAddressAccountMetadata(row) + if err != nil { + return nil, err + } + addrType, origin, err := convertAddressMetadata(row) if err != nil { return nil, err @@ -380,16 +510,22 @@ func AddressRowToInfo[TypeID, OriginIDType any]( } return &AddressInfo{ - ID: id, - AccountID: accountID, - AddrType: addrType, - CreatedAt: row.CreatedAt, - Origin: origin, - Branch: addrBranch, - Index: addrIndex, - ScriptPubKey: row.ScriptPubKey, - PubKey: row.PubKey, - IsWatchOnly: isWatchOnly, + ID: id, + AccountID: accountID, + AccountNumber: accountNumber, + AccountName: accountName, + KeyScope: keyScope, + MasterKeyFingerprint: masterFingerprint, + AddrType: addrType, + CreatedAt: row.CreatedAt, + Origin: origin, + Branch: addrBranch, + Index: addrIndex, + ScriptPubKey: row.ScriptPubKey, + PubKey: row.PubKey, + HasScript: row.HasScript, + IsWatchOnly: isWatchOnly, + IsUsed: row.IsUsed, }, nil } @@ -436,10 +572,21 @@ type DerivedAddressAdapters[QTX any, AccountRow any, AccountParams any, // GetAccountID extracts the account ID from an account row. GetAccountID func(AccountRow) int64 + // GetAccountNumber extracts the BIP44 account number from an account row. + GetAccountNumber func(AccountRow) (uint32, error) + // GetWalletWatchOnly extracts the wallet watch-only state from an account // row. GetWalletWatchOnly func(AccountRow) bool + // GetAccountAddrSchema extracts the effective address schema for the + // account from the looked-up row. SQL backends derive the schema + // from (purpose, coin_type); per-account override is not currently + // modeled in the SQL schema, so imported accounts whose addrType + // differs from the scope default silently use the scope default + // today. + GetAccountAddrSchema func(AccountRow) (ScopeAddrSchema, error) + // GetExtIndex returns a function to get the external index. GetExtIndex func(QTX) func(context.Context, int64) (int64, error) @@ -448,13 +595,17 @@ type DerivedAddressAdapters[QTX any, AccountRow any, AccountParams any, // CreateAddr returns a function to create an address row. CreateAddr func(QTX) func(context.Context, int64, int64, AddressType, - uint32, uint32, []byte) (AddrRow, error) + uint32, uint32, []byte, []byte) (AddrRow, error) // RowID extracts the ID from an address row. RowID func(AddrRow) int64 // RowCreatedAt extracts the creation time from an address row. RowCreatedAt func(AddrRow) time.Time + + // ApplyAccountMetadata copies account metadata from the account row + // onto the address result inside the create transaction. + ApplyAccountMetadata func(*AddressInfo, AccountRow) error } // ImportedAddressAdapters groups the functions needed to create an @@ -494,6 +645,36 @@ type ImportedAddressAdapters[QTX any, AccountRow any, // RowCreatedAt extracts the creation time from an address row. RowCreatedAt func(AddrRow) time.Time + + // ApplyAccountMetadata copies account metadata from the account row + // onto the address result inside the create transaction. + ApplyAccountMetadata func(*AddressInfo, AccountRow) error +} + +// DerivedAddressCreateAddr returns a derived-address insert adapter from a +// backend-specific sqlc create method and parameter builder. +func DerivedAddressCreateAddr[CreateParams any, AddrRow any]( + create func(context.Context, CreateParams) (AddrRow, error), + buildParams func(int64, int64, AddressType, uint32, uint32, []byte, + []byte) (CreateParams, error)) func(context.Context, int64, int64, + AddressType, uint32, uint32, []byte, []byte) (AddrRow, error) { + + return func(ctx context.Context, walletID int64, accountID int64, + addrType AddressType, branch uint32, index uint32, + scriptPubKey []byte, pubKey []byte) (AddrRow, error) { + + params, err := buildParams( + walletID, accountID, addrType, branch, index, scriptPubKey, + pubKey, + ) + if err != nil { + var zero AddrRow + + return zero, err + } + + return create(ctx, params) + } } // GetAddressFunc defines a function signature for retrieving a single address. @@ -512,20 +693,23 @@ func GetAddressByQuery(ctx context.Context, query GetAddressQuery, // createDerivedAddress is a generic helper that encapsulates the shared // derived address creation logic. It calls derivedAddressInput to prepare -// inputs and then createFn to create the address. +// inputs and then createFn to create the address. addrSchema is the +// account's effective schema (per-account override when set, otherwise +// the scope default). func createDerivedAddress[T any](ctx context.Context, params NewDerivedAddressParams, walletID int64, accountID int64, - walletIsWatchOnly bool, + accountNumber uint32, walletIsWatchOnly bool, addrSchema ScopeAddrSchema, getExtIndex func(context.Context, int64) (int64, error), getIntIndex func(context.Context, int64) (int64, error), createFn func(context.Context, int64, int64, AddressType, uint32, uint32, - []byte) (T, error), + []byte, []byte) (T, error), rowID func(T) int64, rowCreatedAt func(T) time.Time, deriveFn AddressDerivationFunc) (*AddressInfo, error) { - addrType, branch, index, scriptPubKey, err := + addrType, branch, index, scriptPubKey, pubKey, err := derivedAddressInput( - ctx, params, accountID, getExtIndex, getIntIndex, deriveFn, + ctx, params, accountID, accountNumber, addrSchema, getExtIndex, + getIntIndex, deriveFn, ) if err != nil { return nil, err @@ -533,6 +717,7 @@ func createDerivedAddress[T any](ctx context.Context, row, err := createFn( ctx, walletID, accountID, addrType, branch, index, scriptPubKey, + pubKey, ) if err != nil { return nil, fmt.Errorf("create address: %w", err) @@ -554,24 +739,23 @@ func createDerivedAddress[T any](ctx context.Context, Branch: branch, Index: index, ScriptPubKey: scriptPubKey, + PubKey: pubKey, IsWatchOnly: walletIsWatchOnly, }, nil } // derivedAddressInput encapsulates the logic to prepare inputs for address -// derivation, including schema lookup, branch/type selection, index -// allocation with overflow check, accountID conversion, and derivation. +// derivation, including branch/type selection from the supplied effective +// schema, index allocation with overflow check, and derivation. addrSchema is +// the account's effective schema and must already account for any per-account +// override; this function does not consult the scope default itself. func derivedAddressInput(ctx context.Context, - params NewDerivedAddressParams, accountID int64, + params NewDerivedAddressParams, accountID int64, accountNumber uint32, + addrSchema ScopeAddrSchema, getExtIndex func(context.Context, int64) (int64, error), getIntIndex func(context.Context, int64) (int64, error), deriveFn AddressDerivationFunc) (AddressType, uint32, uint32, - []byte, error) { - - addrSchema, err := getAddrSchemaForScope(params.Scope) - if err != nil { - return 0, 0, 0, nil, err - } + []byte, []byte, error) { var ( branch uint32 @@ -590,34 +774,33 @@ func derivedAddressInput(ctx context.Context, indexValue, err := getIdx(ctx, accountID) if err != nil { - return 0, 0, 0, nil, fmt.Errorf("get next address index: %w", err) + return 0, 0, 0, nil, nil, + fmt.Errorf("get next address index: %w", err) } if indexValue > math.MaxUint32 { - return 0, 0, 0, nil, ErrMaxAddressIndexReached + return 0, 0, 0, nil, nil, ErrMaxAddressIndexReached } index, err := Int64ToUint32(indexValue) if err != nil { - return 0, 0, 0, nil, fmt.Errorf("address index: %w", err) + return 0, 0, 0, nil, nil, fmt.Errorf("address index: %w", err) } - acctID, err := Int64ToUint32(accountID) - if err != nil { - return 0, 0, 0, nil, fmt.Errorf("account ID: %w", err) - } - - derivedData, err := deriveFn(ctx, acctID, branch, index) + derivedData, err := deriveFn( + ctx, params.Scope, accountNumber, branch, index, + ) if err != nil { - return 0, 0, 0, nil, fmt.Errorf("derive address: %w", err) + return 0, 0, 0, nil, nil, fmt.Errorf("derive address: %w", err) } if derivedData == nil { - return 0, 0, 0, nil, fmt.Errorf("derive address: %w", + return 0, 0, 0, nil, nil, fmt.Errorf("derive address: %w", errNilDerivedAddressData) } - return addrType, branch, index, derivedData.ScriptPubKey, nil + return addrType, branch, index, derivedData.ScriptPubKey, + derivedData.PubKey, nil } // NewDerivedAddressWithTx combines transaction execution, account lookup, @@ -641,9 +824,20 @@ func NewDerivedAddressWithTx[QTX any, AccountRow any, if err == nil { accountID := adapters.GetAccountID(row) + accountNumber, errAccount := adapters.GetAccountNumber(row) + if errAccount != nil { + return fmt.Errorf("account number: %w", errAccount) + } + + addrSchema, errSchema := adapters.GetAccountAddrSchema(row) + if errSchema != nil { + return fmt.Errorf("account addr schema: %w", + errSchema) + } + info, errAddr := createDerivedAddress( ctx, params, int64(params.WalletID), accountID, - adapters.GetWalletWatchOnly(row), + accountNumber, adapters.GetWalletWatchOnly(row), addrSchema, adapters.GetExtIndex(qtx), adapters.GetIntIndex(qtx), adapters.CreateAddr(qtx), @@ -653,6 +847,12 @@ func NewDerivedAddressWithTx[QTX any, AccountRow any, return errAddr } + errMeta := adapters.ApplyAccountMetadata(info, row) + if errMeta != nil { + return fmt.Errorf("apply address account metadata: %w", + errMeta) + } + result = info return nil @@ -714,6 +914,12 @@ func NewImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, return errAddr } + errMeta := adapters.ApplyAccountMetadata(info, row) + if errMeta != nil { + return fmt.Errorf("apply address account metadata: %w", + errMeta) + } + result = info return nil diff --git a/wallet/internal/db/addresses_common_test.go b/wallet/internal/db/addresses_common_test.go index df6f19a2d1..38c8d31856 100644 --- a/wallet/internal/db/addresses_common_test.go +++ b/wallet/internal/db/addresses_common_test.go @@ -37,7 +37,7 @@ func TestDerivedAddressInputNilDerivedData(t *testing.T) { Scope: KeyScopeBIP0084, } - deriveFn := func(context.Context, uint32, uint32, + deriveFn := func(context.Context, KeyScope, uint32, uint32, uint32) (*DerivedAddressData, error) { var derivedData *DerivedAddressData @@ -45,19 +45,25 @@ func TestDerivedAddressInputNilDerivedData(t *testing.T) { return derivedData, nil } - addrType, branch, index, scriptPubKey, err := derivedAddressInput( - t.Context(), params, 1, - func(context.Context, int64) (int64, error) { - return 7, nil - }, - func(context.Context, int64) (int64, error) { - return 11, nil - }, deriveFn, - ) + addrType, branch, index, scriptPubKey, pubKey, err := + derivedAddressInput( + t.Context(), params, 1, 0, + ScopeAddrSchema{ + ExternalAddrType: PubKeyHash, + InternalAddrType: PubKeyHash, + }, + func(context.Context, int64) (int64, error) { + return 7, nil + }, + func(context.Context, int64) (int64, error) { + return 11, nil + }, deriveFn, + ) require.Zero(t, addrType) require.Zero(t, branch) require.Zero(t, index) require.Nil(t, scriptPubKey) + require.Nil(t, pubKey) require.ErrorIs(t, err, errNilDerivedAddressData) } diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index c5b89a6662..db3e9377c3 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -617,6 +617,22 @@ type AddressInfo struct { // databases (signed 64-bit integers). AccountID uint32 + // AccountNumber is the BIP44 account index used for derived accounts. + // Imported accounts do not have a meaningful BIP44 account index, so this + // field is set to 0 for imported rows and must not be used when Origin is + // ImportedAccount. + AccountNumber uint32 + + // AccountName is the human-readable account name that owns the address. + AccountName string + + // KeyScope identifies the wallet scope that owns the address. + KeyScope KeyScope + + // MasterKeyFingerprint is the root fingerprint associated with the owning + // account. It is 0 when the database does not store one for the account. + MasterKeyFingerprint uint32 + // AddrType is the type of address (P2PKH, P2WPKH, P2TR, etc.). AddrType AddressType @@ -638,13 +654,25 @@ type AddressInfo struct { // ScriptPubKey is the script pubkey (plaintext). ScriptPubKey []byte - // PubKey is the public key (plaintext). Zero value for derived - // addresses. + // PubKey is the public key (plaintext) when the address is public-key + // based. PubKey []byte + // HasScript indicates whether the address has encrypted script material. + // This is needed for address families whose output type alone is + // ambiguous, such as P2TR key-path versus P2TR script-path imports. + HasScript bool + // IsWatchOnly indicates whether the address belongs to a watch-only // wallet or does not have private keys. IsWatchOnly bool + + // IsUsed reports whether the address has a non-abandoned + // on-chain transaction the wallet has observed. Monotonic + // across reorgs/replaces; `DeleteTx` clears it along with + // the abandoned tx (see ADR 0011). SQL backends derive via + // EXISTS on utxos; kvdb reads waddrmgr's sticky bit. + IsUsed bool } // AddressSecret contains sensitive encrypted material for an address. diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 7e35c81210..1ce3ecb93b 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -230,18 +230,22 @@ type AccountStore interface { RenameAccount(ctx context.Context, params RenameAccountParams) error } -// AddressDerivationFunc is called by the database layer after allocating an -// address index to derive the actual address data (script_pub_key). As the -// database should not know about how to derive an address, we pass this as a -// callback. -type AddressDerivationFunc func(ctx context.Context, accountID uint32, - branch uint32, index uint32) (*DerivedAddressData, error) +// AddressDerivationFunc derives address data after a SQL backend allocates an +// address index. The callback receives the key scope and BIP44 account number, +// not the database account row ID. +type AddressDerivationFunc func(ctx context.Context, scope KeyScope, + accountNumber uint32, branch uint32, + index uint32) (*DerivedAddressData, error) // DerivedAddressData contains the derived address information returned by // the AddressDerivationFunc callback. type DerivedAddressData struct { // ScriptPubKey is the script public key for the derived address. ScriptPubKey []byte + + // PubKey is the serialized public key for the derived address when one is + // available. Script-only addresses leave this empty. + PubKey []byte } // AccountDerivationFunc is invoked by the database layer after allocating a @@ -278,12 +282,11 @@ type DerivedAccountData struct { // AddressStore defines the database actions for managing addresses. type AddressStore interface { // NewDerivedAddress creates a new HD-derived address for the specified - // account and key scope. The database layer allocates the address index - // atomically, then calls deriveFn to derive the actual address data. - // Returns the complete address metadata including the derived - // script_pub_key. - NewDerivedAddress(ctx context.Context, params NewDerivedAddressParams, - deriveFn AddressDerivationFunc) (*AddressInfo, error) + // account and key scope. The concrete backend owns address derivation: + // SQL backends use their configured AddressDerivationFunc, while kvdb + // preserves legacy waddrmgr derivation semantics. + NewDerivedAddress(ctx context.Context, + params NewDerivedAddressParams) (*AddressInfo, error) // NewImportedAddress imports a new address, script, or private key. // If a private key is provided in the parameters, the address will diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index d4638286bb..c57ef4e071 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -19,17 +19,19 @@ import ( "github.com/stretchr/testify/require" ) -// mockDeriveFunc is a test helper that returns a mock AddressDerivationFunc -// for testing NewDerivedAddress. It generates deterministic script_pub_key -// values based on accountID, branch, and index to ensure uniqueness. +// mockDeriveFunc returns a test address derivation function. It generates +// deterministic script pubkeys from scope, account number, branch, and index so +// derived test addresses are unique across scopes. func mockDeriveFunc() db.AddressDerivationFunc { - return func(ctx context.Context, accountID uint32, branch uint32, - index uint32) (*db.DerivedAddressData, error) { + return func(ctx context.Context, scope db.KeyScope, accountNumber uint32, + branch uint32, index uint32) (*db.DerivedAddressData, error) { scriptPubKey := make([]byte, 20) - binary.BigEndian.PutUint32(scriptPubKey[0:4], accountID) + binary.BigEndian.PutUint32(scriptPubKey[0:4], accountNumber) binary.BigEndian.PutUint32(scriptPubKey[4:8], branch) binary.BigEndian.PutUint32(scriptPubKey[8:12], index) + binary.BigEndian.PutUint32(scriptPubKey[12:16], scope.Purpose) + binary.BigEndian.PutUint32(scriptPubKey[16:20], scope.Coin) return &db.DerivedAddressData{ ScriptPubKey: scriptPubKey, @@ -49,7 +51,7 @@ func newDerivedAddress(t *testing.T, store db.AddressStore, walletID uint32, Scope: scope, AccountName: accountName, Change: change, - }, mockDeriveFunc(), + }, ) require.NoError(t, err) @@ -242,13 +244,22 @@ func TestNewImportedAddress(t *testing.T) { require.NotNil(t, info.ScriptPubKey) require.Equal(t, tc.expectedAddrType, info.AddrType) - // Verify account imported_key_count incremented. + // Verify address creation returned complete account metadata. account, err := store.GetAccount( t.Context(), getAccountQueryByName( walletID, tc.scope, "imported", ), ) require.NoError(t, err) + require.Equal(t, account.AccountNumber, info.AccountNumber) + require.Equal(t, account.AccountName, info.AccountName) + require.Equal(t, account.KeyScope, info.KeyScope) + require.Equal( + t, account.MasterKeyFingerprint, + info.MasterKeyFingerprint, + ) + + // Verify account imported_key_count incremented. require.Positive(t, account.ImportedKeyCount) // Verify address_secrets row for imported addresses. @@ -1472,6 +1483,13 @@ func TestListAddresses(t *testing.T) { require.Equal(t, uint32(0), addr.Branch) require.Equal(t, db.DerivedAccount, addr.Origin) require.False(t, addr.IsWatchOnly) + require.Equal(t, uint32(0), addr.AccountNumber) + require.Equal(t, "test-account", addr.AccountName) + require.Equal(t, db.KeyScopeBIP0044, addr.KeyScope) + require.Equal( + t, uint32(0xC0DEC0DE), + addr.MasterKeyFingerprint, + ) } require.False(t, addrs[0].IsWatchOnly) @@ -1651,6 +1669,9 @@ func TestNewDerivedAddress(t *testing.T) { // Create account in BIP44 scope. accountName := "derived-test" createDerivedAccount(t, store, walletID, db.KeyScopeBIP0044, accountName) + account := getAccountByName( + t, store, walletID, db.KeyScopeBIP0044, accountName, + ) testCases := []struct { name string @@ -1683,29 +1704,120 @@ func TestNewDerivedAddress(t *testing.T) { require.Equal(t, tc.expectedBranch, info.Branch) require.NotNil(t, info.ScriptPubKey) require.Nil(t, info.PubKey) + require.Equal(t, account.AccountNumber, info.AccountNumber) + require.Equal(t, account.AccountName, info.AccountName) + require.Equal(t, account.KeyScope, info.KeyScope) + require.Equal( + t, account.MasterKeyFingerprint, + info.MasterKeyFingerprint, + ) }) } } +// TestNewDerivedAddressUsesStoredScopeSchema verifies that derived addresses +// use the address schema persisted on the key scope instead of recomputing the +// default schema from the scope tuple. +func TestNewDerivedAddressUsesStoredScopeSchema(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-derived-stored-schema") + + strictBIP49 := &db.ScopeAddrSchema{ + ExternalAddrType: db.NestedWitnessPubKey, + InternalAddrType: db.NestedWitnessPubKey, + } + + _, err := store.CreateImportedAccount( + t.Context(), db.CreateImportedAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0049Plus, + Name: "strict-bip49-import", + PublicKey: RandomBytes(32), + AddrSchema: strictBIP49, + }, + ) + require.NoError(t, err) + + accountName := "derived-on-strict-bip49" + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0049Plus, accountName, + ) + + info := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0049Plus, accountName, true, + ) + require.Equal(t, db.NestedWitnessPubKey, info.AddrType) +} + +// TestNewDerivedAddressDerivesByAccountNumber verifies that the derivation +// callback receives the BIP44 account number, not the SQL account row ID. +func TestNewDerivedAddressDerivesByAccountNumber(t *testing.T) { + t.Parallel() + + var derivedAccountNumber uint32 + + baseDerive := mockDeriveFunc() + deriveFn := func(ctx context.Context, scope db.KeyScope, + accountNumber uint32, branch uint32, + index uint32) (*db.DerivedAddressData, error) { + + derivedAccountNumber = accountNumber + + return baseDerive(ctx, scope, accountNumber, branch, index) + } + + store := NewTestStoreWithDerive(t, deriveFn) + queries := store.Queries() + walletID := newWallet(t, store, "wallet-derived-account-number") + + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0084, "first-account", + ) + + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + + const accountNumber uint32 = 17 + + accountName := "account-number-17" + CreateAccountWithNumber(t, queries, scopeID, accountNumber, accountName) + + info, err := store.NewDerivedAddress( + t.Context(), db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: accountName, + }, + ) + require.NoError(t, err) + + require.NotEqual(t, info.AccountID, info.AccountNumber) + require.Equal(t, accountNumber, info.AccountNumber) + require.Equal(t, accountNumber, derivedAccountNumber) +} + // TestNewDerivedAddressDerivationGuards verifies that NewDerivedAddress returns // errors instead of panicking when the derivation callback is nil or returns // nil derived data. func TestNewDerivedAddressDerivationGuards(t *testing.T) { t.Parallel() - store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-derived-guards") - accountName := "derived-guard-test" - createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + t.Run("nil derive callback", func(t *testing.T) { + store := NewTestStoreWithDerive(t, nil) + walletID := newWallet(t, store, "wallet-derived-nil-derive") + accountName := "derived-guard-test" + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0084, accountName, + ) - params := db.NewDerivedAddressParams{ - WalletID: walletID, - Scope: db.KeyScopeBIP0084, - AccountName: accountName, - Change: false, - } + params := db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: accountName, + Change: false, + } - t.Run("nil derive callback", func(t *testing.T) { var ( info *db.AddressInfo err error @@ -1713,7 +1825,7 @@ func TestNewDerivedAddressDerivationGuards(t *testing.T) { require.NotPanics( t, func() { - info, err = store.NewDerivedAddress(t.Context(), params, nil) + info, err = store.NewDerivedAddress(t.Context(), params) }, ) @@ -1722,12 +1834,26 @@ func TestNewDerivedAddressDerivationGuards(t *testing.T) { }) t.Run("nil derived data", func(t *testing.T) { - deriveFn := func(ctx context.Context, accountID uint32, branch uint32, - index uint32) (*db.DerivedAddressData, error) { + deriveFn := func(ctx context.Context, scope db.KeyScope, + accountNumber, branch, index uint32) (*db.DerivedAddressData, + error) { //nolint:nilnil // Intentionally exercise nil-data success guard. return nil, nil } + store := NewTestStoreWithDerive(t, deriveFn) + walletID := newWallet(t, store, "wallet-derived-nil-data") + accountName := "derived-guard-test" + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0084, accountName, + ) + + params := db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: accountName, + Change: false, + } var ( info *db.AddressInfo @@ -1736,9 +1862,7 @@ func TestNewDerivedAddressDerivationGuards(t *testing.T) { require.NotPanics( t, func() { - info, err = store.NewDerivedAddress( - t.Context(), params, deriveFn, - ) + info, err = store.NewDerivedAddress(t.Context(), params) }, ) @@ -1822,7 +1946,7 @@ func TestGetAddressSecret_DerivedAddress(t *testing.T) { Change: false, } addrInfo, err := store.NewDerivedAddress( - t.Context(), params, mockDeriveFunc(), + t.Context(), params, ) require.NoError(t, err) @@ -2389,7 +2513,7 @@ func TestNewDerivedAddressErrors(t *testing.T) { tc.params.WalletID = walletID info, err := store.NewDerivedAddress( - t.Context(), tc.params, mockDeriveFunc(), + t.Context(), tc.params, ) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, info) @@ -2421,7 +2545,7 @@ func TestNewDerivedAddress_WalletAccountMismatch(t *testing.T) { Scope: db.KeyScopeBIP0084, AccountName: accountName, Change: false, - }, mockDeriveFunc(), + }, ) require.ErrorIs(t, err, db.ErrAccountNotFound) require.Nil(t, info) @@ -2452,8 +2576,6 @@ func TestNewDerivedAddressConcurrent(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() - deriveFn := mockDeriveFunc() - for range workers { wg.Add(1) @@ -2466,7 +2588,7 @@ func TestNewDerivedAddressConcurrent(t *testing.T) { Scope: db.KeyScopeBIP0084, AccountName: accountName, Change: false, - }, deriveFn, + }, ) if err != nil { resultCh <- deriveResult{err: err} @@ -2641,7 +2763,7 @@ func TestNewDerivedAddressMaxIndex(t *testing.T) { Scope: db.KeyScopeBIP0084, AccountName: "max-acct", Change: false, - }, mockDeriveFunc(), + }, ) require.ErrorIs(t, err, db.ErrMaxAddressIndexReached) } @@ -2681,7 +2803,7 @@ func TestNewDerivedAddressMaxIndexInternal(t *testing.T) { Scope: db.KeyScopeBIP0084, AccountName: "max-acct", Change: true, - }, mockDeriveFunc(), + }, ) require.ErrorIs(t, err, db.ErrMaxAddressIndexReached) } diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 0d4ebe4ef4..f323bd286e 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -250,6 +250,16 @@ func sanitizedPgDBName(t *testing.T) string { // creating NewTestStore inside each parallel subtest so its lifecycle is tied // to the subtest's parallel slot. func NewTestStore(t *testing.T) *pg.Store { + t.Helper() + + return NewTestStoreWithDerive(t, mockDeriveFunc()) +} + +// NewTestStoreWithDerive creates a new PostgreSQL database for testing with the +// provided address derivation function. +func NewTestStoreWithDerive(t *testing.T, + deriveAddress db.AddressDerivationFunc) *pg.Store { + t.Helper() ctx := t.Context() @@ -282,6 +292,7 @@ func NewTestStore(t *testing.T) *pg.Store { cfg := pg.Config{ Dsn: testConnStr, MaxConnections: 0, + DeriveAddress: deriveAddress, } store, err := pg.NewStore(t.Context(), cfg) diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 44d87ce017..7af1db2192 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -21,12 +21,23 @@ import ( func NewTestStore(t *testing.T) *sqlite.Store { t.Helper() + return NewTestStoreWithDerive(t, mockDeriveFunc()) +} + +// NewTestStoreWithDerive creates a new SQLite database for testing with the +// provided address derivation function. +func NewTestStoreWithDerive(t *testing.T, + deriveAddress db.AddressDerivationFunc) *sqlite.Store { + + t.Helper() + tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") cfg := sqlite.Config{ DBPath: dbPath, MaxConnections: 0, + DeriveAddress: deriveAddress, } store, err := sqlite.NewStore(t.Context(), cfg) diff --git a/wallet/internal/db/kvdb/addressstore.go b/wallet/internal/db/kvdb/addressstore.go index 8613b64194..6944111791 100644 --- a/wallet/internal/db/kvdb/addressstore.go +++ b/wallet/internal/db/kvdb/addressstore.go @@ -10,8 +10,7 @@ import ( // NewDerivedAddress is not yet implemented for kvdb. func (s *Store) NewDerivedAddress(ctx context.Context, - _ db.NewDerivedAddressParams, - _ db.AddressDerivationFunc) (*db.AddressInfo, error) { + _ db.NewDerivedAddressParams) (*db.AddressInfo, error) { return nil, notImplemented(ctx, "NewDerivedAddress") } diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index f79c3a4a80..f581d5cc02 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -14,7 +14,9 @@ import ( // Ensure Store satisfies the AccountStore interface. var _ db.AccountStore = (*Store)(nil) -var errDryRunRollback = errors.New("postgres imported account dry run rollback") +var ( + errDryRunRollback = errors.New("postgres imported account dry run rollback") +) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. @@ -377,6 +379,14 @@ func derivedAddressGetAccountID( return row.ID } +// derivedAddressGetAccountNumber extracts the derived account number from a +// row. +func derivedAddressGetAccountNumber( + row sqlc.GetAccountByWalletScopeAndNameRow) (uint32, error) { + + return db.DerivedAddressAccountNumber(row.AccountNumber) +} + // derivedAddressGetWalletWatchOnly extracts the wallet-level watch-only state // from a row. func derivedAddressGetWalletWatchOnly( @@ -385,6 +395,17 @@ func derivedAddressGetWalletWatchOnly( return row.WalletIsWatchOnly } +// derivedAddressGetAccountAddrSchema returns the address schema persisted for +// the account's key scope. +func derivedAddressGetAccountAddrSchema( + row sqlc.GetAccountByWalletScopeAndNameRow) (db.ScopeAddrSchema, + error) { + + return db.DerivedAddressAccountSchema( + row.InternalTypeID, row.ExternalTypeID, + ) +} + // importedAddressGetAccountID extracts the account ID from a row. func importedAddressGetAccountID( row sqlc.GetAccountByWalletScopeAndNameRow) int64 { diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index a7cd61f47d..0e45c71f10 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -123,27 +123,29 @@ func (s *Store) GetAddressSecret(ctx context.Context, // NewDerivedAddress creates a new address for a given account and key // scope. func (s *Store) NewDerivedAddress(ctx context.Context, - params db.NewDerivedAddressParams, - deriveFn db.AddressDerivationFunc) (*db.AddressInfo, error) { + params db.NewDerivedAddressParams) (*db.AddressInfo, error) { adapters := db.DerivedAddressAdapters[ *sqlc.Queries, sqlc.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, sqlc.CreateDerivedAddressRow]{ - GetAccount: getAccountFromKey(s.queries), - AccountParams: db.AccountKeyFromParams, - GetAccountID: derivedAddressGetAccountID, - GetWalletWatchOnly: derivedAddressGetWalletWatchOnly, - GetExtIndex: derivedAddressGetExtIndex, - GetIntIndex: derivedAddressGetIntIndex, - CreateAddr: derivedAddressCreateAddr, - RowID: derivedAddressRowID, - RowCreatedAt: derivedAddressRowCreatedAt, + GetAccount: getAccountFromKey(s.queries), + AccountParams: db.AccountKeyFromParams, + GetAccountID: derivedAddressGetAccountID, + GetAccountNumber: derivedAddressGetAccountNumber, + GetWalletWatchOnly: derivedAddressGetWalletWatchOnly, + GetAccountAddrSchema: derivedAddressGetAccountAddrSchema, + GetExtIndex: derivedAddressGetExtIndex, + GetIntIndex: derivedAddressGetIntIndex, + CreateAddr: derivedAddressCreateAddr, + RowID: derivedAddressRowID, + RowCreatedAt: derivedAddressRowCreatedAt, + ApplyAccountMetadata: applyAddressAccountMetadata, } return db.NewDerivedAddressWithTx( - ctx, params, s.execWrite, adapters, deriveFn, + ctx, params, s.execWrite, adapters, s.deriveAddress, ) } @@ -158,19 +160,22 @@ func (s *Store) NewImportedAddress(ctx context.Context, sqlc.CreateImportedAddressParams, sqlc.CreateImportedAddressRow, sqlc.InsertAddressSecretParams]{ - GetAccount: getAccountFromKey(s.queries), - AccountParams: db.AccountKeyFromImportedParams, - GetAccountID: importedAddressGetAccountID, - GetWalletWatchOnly: importedAddressGetWalletWatchOnly, - CreateAddr: createImportedAddress, - CreateParams: createImportedAddressParams, - InsertSecret: insertAddressSecret, - SecretParams: insertAddressSecretParams, - RowID: importedAddressRowID, - RowCreatedAt: importedAddressRowCreatedAt, + GetAccount: getAccountFromKey(s.queries), + AccountParams: db.AccountKeyFromImportedParams, + GetAccountID: importedAddressGetAccountID, + GetWalletWatchOnly: importedAddressGetWalletWatchOnly, + CreateAddr: createImportedAddress, + CreateParams: createImportedAddressParams, + InsertSecret: insertAddressSecret, + SecretParams: insertAddressSecretParams, + RowID: importedAddressRowID, + RowCreatedAt: importedAddressRowCreatedAt, + ApplyAccountMetadata: applyAddressAccountMetadata, } - return db.NewImportedAddressWithTx(ctx, params, s.execWrite, adapters) + return db.NewImportedAddressWithTx( + ctx, params, s.execWrite, adapters, + ) } // getAccountFromKey returns a helper to look up accounts by key. @@ -209,37 +214,41 @@ func derivedAddressGetIntIndex(qtx *sqlc.Queries) func(context.Context, // derivedAddressCreateAddr returns the derived address insert helper. func derivedAddressCreateAddr(qtx *sqlc.Queries) func( context.Context, int64, int64, db.AddressType, uint32, uint32, []byte, -) (sqlc.CreateDerivedAddressRow, error) { + []byte) (sqlc.CreateDerivedAddressRow, error) { - return func(ctx context.Context, walletID int64, accountID int64, - addrType db.AddressType, branch uint32, index uint32, - scriptPubKey []byte) (sqlc.CreateDerivedAddressRow, error) { + return db.DerivedAddressCreateAddr( + qtx.CreateDerivedAddress, buildDerivedAddressParams, + ) +} - branchNum, err := db.Uint32ToInt16(branch) - if err != nil { - return sqlc.CreateDerivedAddressRow{}, fmt.Errorf( - "address branch: %w", err, - ) - } +// buildDerivedAddressParams maps common derived-address inputs to PostgreSQL +// sqlc insert params. +func buildDerivedAddressParams(walletID int64, accountID int64, + addrType db.AddressType, branch uint32, index uint32, + scriptPubKey []byte, + pubKey []byte) (sqlc.CreateDerivedAddressParams, error) { - return qtx.CreateDerivedAddress( - ctx, sqlc.CreateDerivedAddressParams{ - WalletID: walletID, - AccountID: accountID, - ScriptPubKey: scriptPubKey, - TypeID: int16(addrType), - AddressBranch: sql.NullInt16{ - Int16: branchNum, - Valid: true, - }, - AddressIndex: sql.NullInt64{ - Int64: int64(index), - Valid: true, - }, - PubKey: nil, - }, - ) + branchNum, err := db.Uint32ToInt16(branch) + if err != nil { + return sqlc.CreateDerivedAddressParams{}, + fmt.Errorf("address branch: %w", err) } + + return sqlc.CreateDerivedAddressParams{ + WalletID: walletID, + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int16(addrType), + AddressBranch: sql.NullInt16{ + Int16: branchNum, + Valid: true, + }, + AddressIndex: sql.NullInt64{ + Int64: int64(index), + Valid: true, + }, + PubKey: pubKey, + }, nil } // derivedAddressRowID returns the created address ID. @@ -309,6 +318,17 @@ func importedAddressRowCreatedAt( return row.CreatedAt } +// applyAddressAccountMetadata copies account metadata from the account lookup +// row onto an address creation result before the write transaction commits. +func applyAddressAccountMetadata(info *db.AddressInfo, + row sqlc.GetAccountByWalletScopeAndNameRow) error { + + return db.ApplyAddressAccountMetadata( + info, row.AccountNumber, row.AccountName, + row.MasterFingerprint, row.Purpose, row.CoinType, + ) +} + // addressSecretRowToSecret converts a PostgreSQL address secret row to an // AddressSecret struct. func addressSecretRowToSecret( @@ -329,8 +349,7 @@ type addressInfoRow interface { sqlc.ListAddressesByAccountRow } -// addressRowToInfo converts a PostgreSQL address row to an AddressInfo -// struct. +// addressRowToInfo converts a PostgreSQL address row to an AddressInfo struct. func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. @@ -339,6 +358,11 @@ func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { info, err := db.AddressRowToInfo(db.AddressInfoRow[int16, int16]{ ID: base.ID, AccountID: base.AccountID, + AccountNumber: base.AccountNumber, + AccountName: base.AccountName, + MasterFingerprint: base.MasterFingerprint, + Purpose: base.Purpose, + CoinType: base.CoinType, TypeID: base.TypeID, OriginID: base.OriginID, WalletIsWatchOnly: base.WalletIsWatchOnly, @@ -352,6 +376,7 @@ func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { AddressIndex: base.AddressIndex, ScriptPubKey: base.ScriptPubKey, PubKey: base.PubKey, + IsUsed: base.IsUsed, IDToAddrType: db.IDToAddressType[int16], IDToOrigin: db.IDToOrigin[int16], }) diff --git a/wallet/internal/db/pg/config.go b/wallet/internal/db/pg/config.go index 8a9aed180d..06c1d3754c 100644 --- a/wallet/internal/db/pg/config.go +++ b/wallet/internal/db/pg/config.go @@ -15,6 +15,11 @@ type Config struct { // MaxConnections is the maximum number of open connections to the // database. Set to zero to use db.DefaultMaxConnections. MaxConnections int + + // DeriveAddress derives address data for NewDerivedAddress after the + // store allocates an account branch/index. It may be nil when the store + // is used only for operations that do not create derived addresses. + DeriveAddress db.AddressDerivationFunc } // Validate checks that the Config values are valid. diff --git a/wallet/internal/db/pg/store.go b/wallet/internal/db/pg/store.go index 7c373256da..3c899cf10a 100644 --- a/wallet/internal/db/pg/store.go +++ b/wallet/internal/db/pg/store.go @@ -21,6 +21,9 @@ type Store struct { // queries executes PostgreSQL statements on db. queries *sqlc.Queries + // deriveAddress derives address data for SQL-derived address rows. + deriveAddress db.AddressDerivationFunc + // runtimeStats tracks shared runtime counters and unhealthy state. runtimeStats dbruntime.Stats } @@ -68,8 +71,9 @@ func NewStore(ctx context.Context, cfg Config) (*Store, } return &Store{ - db: dbConn, - queries: queries, + db: dbConn, + queries: queries, + deriveAddress: cfg.DeriveAddress, }, nil } diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index 1d885485cd..6f307d0cd9 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -14,7 +14,9 @@ import ( // Ensure Store satisfies the AccountStore interface. var _ db.AccountStore = (*Store)(nil) -var errDryRunRollback = errors.New("sqlite imported account dry run rollback") +var ( + errDryRunRollback = errors.New("sqlite imported account dry run rollback") +) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. @@ -382,6 +384,14 @@ func derivedAddressGetAccountID( return row.ID } +// derivedAddressGetAccountNumber extracts the derived account number from a +// row. +func derivedAddressGetAccountNumber( + row sqlc.GetAccountByWalletScopeAndNameRow) (uint32, error) { + + return db.DerivedAddressAccountNumber(row.AccountNumber) +} + // derivedAddressGetWalletWatchOnly extracts the wallet-level watch-only state // from a row. func derivedAddressGetWalletWatchOnly( @@ -390,6 +400,17 @@ func derivedAddressGetWalletWatchOnly( return row.WalletIsWatchOnly } +// derivedAddressGetAccountAddrSchema returns the address schema persisted for +// the account's key scope. +func derivedAddressGetAccountAddrSchema( + row sqlc.GetAccountByWalletScopeAndNameRow) (db.ScopeAddrSchema, + error) { + + return db.DerivedAddressAccountSchema( + row.InternalTypeID, row.ExternalTypeID, + ) +} + // importedAddressGetAccountID extracts the account ID from a row. func importedAddressGetAccountID( row sqlc.GetAccountByWalletScopeAndNameRow) int64 { diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 1f787ffde0..f3c76fbfc9 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -124,27 +124,29 @@ func (s *Store) GetAddressSecret(ctx context.Context, // NewDerivedAddress creates a new address for a given account and key // scope. func (s *Store) NewDerivedAddress(ctx context.Context, - params db.NewDerivedAddressParams, - deriveFn db.AddressDerivationFunc) (*db.AddressInfo, error) { + params db.NewDerivedAddressParams) (*db.AddressInfo, error) { adapters := db.DerivedAddressAdapters[ *sqlc.Queries, sqlc.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, sqlc.CreateDerivedAddressRow]{ - GetAccount: getAccountFromKey(s.queries), - AccountParams: db.AccountKeyFromParams, - GetAccountID: derivedAddressGetAccountID, - GetWalletWatchOnly: derivedAddressGetWalletWatchOnly, - GetExtIndex: derivedAddressGetExtIndex, - GetIntIndex: derivedAddressGetIntIndex, - CreateAddr: derivedAddressCreateAddr, - RowID: derivedAddressRowID, - RowCreatedAt: derivedAddressRowCreatedAt, + GetAccount: getAccountFromKey(s.queries), + AccountParams: db.AccountKeyFromParams, + GetAccountID: derivedAddressGetAccountID, + GetAccountNumber: derivedAddressGetAccountNumber, + GetWalletWatchOnly: derivedAddressGetWalletWatchOnly, + GetAccountAddrSchema: derivedAddressGetAccountAddrSchema, + GetExtIndex: derivedAddressGetExtIndex, + GetIntIndex: derivedAddressGetIntIndex, + CreateAddr: derivedAddressCreateAddr, + RowID: derivedAddressRowID, + RowCreatedAt: derivedAddressRowCreatedAt, + ApplyAccountMetadata: applyAddressAccountMetadata, } return db.NewDerivedAddressWithTx( - ctx, params, s.execWrite, adapters, deriveFn, + ctx, params, s.execWrite, adapters, s.deriveAddress, ) } @@ -159,19 +161,22 @@ func (s *Store) NewImportedAddress(ctx context.Context, sqlc.CreateImportedAddressParams, sqlc.CreateImportedAddressRow, sqlc.InsertAddressSecretParams]{ - GetAccount: getAccountFromKey(s.queries), - AccountParams: db.AccountKeyFromImportedParams, - GetAccountID: importedAddressGetAccountID, - GetWalletWatchOnly: importedAddressGetWalletWatchOnly, - CreateAddr: createImportedAddress, - CreateParams: createImportedAddressParams, - InsertSecret: insertAddressSecret, - SecretParams: insertAddressSecretParams, - RowID: importedAddressRowID, - RowCreatedAt: importedAddressRowCreatedAt, + GetAccount: getAccountFromKey(s.queries), + AccountParams: db.AccountKeyFromImportedParams, + GetAccountID: importedAddressGetAccountID, + GetWalletWatchOnly: importedAddressGetWalletWatchOnly, + CreateAddr: createImportedAddress, + CreateParams: createImportedAddressParams, + InsertSecret: insertAddressSecret, + SecretParams: insertAddressSecretParams, + RowID: importedAddressRowID, + RowCreatedAt: importedAddressRowCreatedAt, + ApplyAccountMetadata: applyAddressAccountMetadata, } - return db.NewImportedAddressWithTx(ctx, params, s.execWrite, adapters) + return db.NewImportedAddressWithTx( + ctx, params, s.execWrite, adapters, + ) } // getAccountFromKey returns a helper to look up accounts by key. @@ -208,34 +213,37 @@ func derivedAddressGetIntIndex( } // derivedAddressCreateAddr returns the derived address insert helper. -func derivedAddressCreateAddr( - qtx *sqlc.Queries, -) func(context.Context, int64, int64, db.AddressType, uint32, uint32, []byte) ( - sqlc.CreateDerivedAddressRow, error, -) { - - return func(ctx context.Context, walletID int64, accountID int64, - addrType db.AddressType, branch uint32, index uint32, - scriptPubKey []byte) (sqlc.CreateDerivedAddressRow, error) { - - return qtx.CreateDerivedAddress( - ctx, sqlc.CreateDerivedAddressParams{ - WalletID: walletID, - AccountID: accountID, - ScriptPubKey: scriptPubKey, - TypeID: int64(addrType), - AddressBranch: sql.NullInt64{ - Int64: int64(branch), - Valid: true, - }, - AddressIndex: sql.NullInt64{ - Int64: int64(index), - Valid: true, - }, - PubKey: nil, - }, - ) - } +func derivedAddressCreateAddr(qtx *sqlc.Queries) func( + context.Context, int64, int64, db.AddressType, uint32, uint32, []byte, + []byte) (sqlc.CreateDerivedAddressRow, error) { + + return db.DerivedAddressCreateAddr( + qtx.CreateDerivedAddress, buildDerivedAddressParams, + ) +} + +// buildDerivedAddressParams maps common derived-address inputs to SQLite sqlc +// insert params. +func buildDerivedAddressParams(walletID int64, accountID int64, + addrType db.AddressType, branch uint32, index uint32, + scriptPubKey []byte, + pubKey []byte) (sqlc.CreateDerivedAddressParams, error) { + + return sqlc.CreateDerivedAddressParams{ + WalletID: walletID, + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int64(addrType), + AddressBranch: sql.NullInt64{ + Int64: int64(branch), + Valid: true, + }, + AddressIndex: sql.NullInt64{ + Int64: int64(index), + Valid: true, + }, + PubKey: pubKey, + }, nil } // derivedAddressRowID returns the created address ID. @@ -292,6 +300,17 @@ func importedAddressRowCreatedAt( return row.CreatedAt } +// applyAddressAccountMetadata copies account metadata from the account lookup +// row onto an address creation result before the write transaction commits. +func applyAddressAccountMetadata(info *db.AddressInfo, + row sqlc.GetAccountByWalletScopeAndNameRow) error { + + return db.ApplyAddressAccountMetadata( + info, row.AccountNumber, row.AccountName, + row.MasterFingerprint, row.Purpose, row.CoinType, + ) +} + // insertAddressSecretParams maps imported params to secret params. func insertAddressSecretParams(addressID int64, params db.NewImportedAddressParams) sqlc.InsertAddressSecretParams { @@ -327,10 +346,8 @@ type addressInfoRow interface { sqlc.ListAddressesByAccountRow } -// addressRowToInfo converts a SQLite address row to an AddressInfo -// struct. -func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, - error) { +// addressRowToInfo converts a SQLite address row to an AddressInfo struct. +func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlc.GetAddressByScriptPubKeyRow(row) @@ -338,6 +355,11 @@ func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, info, err := db.AddressRowToInfo(db.AddressInfoRow[int64, int64]{ ID: base.ID, AccountID: base.AccountID, + AccountNumber: base.AccountNumber, + AccountName: base.AccountName, + MasterFingerprint: base.MasterFingerprint, + Purpose: base.Purpose, + CoinType: base.CoinType, TypeID: base.TypeID, OriginID: base.OriginID, WalletIsWatchOnly: base.WalletIsWatchOnly, @@ -348,6 +370,7 @@ func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, AddressIndex: base.AddressIndex, ScriptPubKey: base.ScriptPubKey, PubKey: base.PubKey, + IsUsed: base.IsUsed, IDToAddrType: db.IDToAddressType[int64], IDToOrigin: db.IDToOrigin[int64], }) diff --git a/wallet/internal/db/sqlite/config.go b/wallet/internal/db/sqlite/config.go index a069a01a9e..7a2019f2df 100644 --- a/wallet/internal/db/sqlite/config.go +++ b/wallet/internal/db/sqlite/config.go @@ -10,6 +10,11 @@ type Config struct { // MaxConnections is the maximum number of open connections to the // database. Set to zero to use db.DefaultMaxConnections. MaxConnections int + + // DeriveAddress derives address data for NewDerivedAddress after the + // store allocates an account branch/index. It may be nil when the store + // is used only for operations that do not create derived addresses. + DeriveAddress db.AddressDerivationFunc } // Validate checks that the Config values are valid. diff --git a/wallet/internal/db/sqlite/store.go b/wallet/internal/db/sqlite/store.go index 09acdc32d1..aa88f4f5cc 100644 --- a/wallet/internal/db/sqlite/store.go +++ b/wallet/internal/db/sqlite/store.go @@ -20,6 +20,9 @@ type Store struct { // queries executes SQLite statements on db. queries *sqlc.Queries + // deriveAddress derives address data for SQL-derived address rows. + deriveAddress db.AddressDerivationFunc + // runtimeStats tracks shared runtime counters and unhealthy state. runtimeStats dbruntime.Stats } @@ -74,8 +77,9 @@ func NewStore(ctx context.Context, cfg Config) (*Store, } return &Store{ - db: dbConn, - queries: queries, + db: dbConn, + queries: queries, + deriveAddress: cfg.DeriveAddress, }, nil } diff --git a/wallet/internal/sql/pg/migrations/000006_addresses.up.sql b/wallet/internal/sql/pg/migrations/000006_addresses.up.sql index 9710caed03..2da6be5125 100644 --- a/wallet/internal/sql/pg/migrations/000006_addresses.up.sql +++ b/wallet/internal/sql/pg/migrations/000006_addresses.up.sql @@ -1,6 +1,13 @@ -- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). -- This ensures migration tracking stays accurate and fails loudly if run twice. +-- This table intentionally does NOT include a `used` column. +-- An address's used-ness is derived from the utxos table +-- (EXISTS(SELECT 1 FROM utxos WHERE address_id = ?)). The derivation +-- is monotonic because utxo rows are preserved through reorgs via +-- tx_status soft-delete (see ADR 0006) and ON DELETE RESTRICT. See +-- ADR 0011 for the full design rationale. +-- -- Addresses table stores all addresses under each account. Addresses can be -- either HD-derived (following BIP32/BIP44 derivation paths) or imported from -- external sources (e.g., watch-only addresses, hardware wallet addresses). diff --git a/wallet/internal/sql/pg/queries/accounts.sql b/wallet/internal/sql/pg/queries/accounts.sql index 55b58b38ee..a91116ef29 100644 --- a/wallet/internal/sql/pg/queries/accounts.sql +++ b/wallet/internal/sql/pg/queries/accounts.sql @@ -66,6 +66,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -93,6 +95,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -120,6 +124,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -151,6 +157,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -183,6 +191,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -208,6 +218,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -237,6 +249,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -269,6 +283,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -298,6 +314,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, diff --git a/wallet/internal/sql/pg/queries/addresses.sql b/wallet/internal/sql/pg/queries/addresses.sql index 48fd8f3925..33e0aef5a1 100644 --- a/wallet/internal/sql/pg/queries/addresses.sql +++ b/wallet/internal/sql/pg/queries/addresses.sql @@ -15,6 +15,10 @@ INSERT INTO address_secrets ( SELECT a.id, a.account_id, + acc.account_number, + acc.account_name, + ks.purpose, + ks.coin_type, a.type_id, a.address_branch, a.address_index, @@ -22,11 +26,18 @@ SELECT a.pub_key, a.created_at, acc.origin_id, + acc.master_fingerprint, w.is_watch_only AS wallet_is_watch_only, (s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key, - (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script + (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script, + exists( + SELECT 1 + FROM utxos AS u + WHERE u.address_id = a.id + ) AS is_used FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id INNER JOIN wallets AS w ON a.wallet_id = w.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE a.script_pub_key = $1 AND a.wallet_id = $2; @@ -82,6 +93,10 @@ RETURNING id, created_at; SELECT a.id, a.account_id, + acc.account_number, + acc.account_name, + ks.purpose, + ks.coin_type, a.type_id, a.address_branch, a.address_index, @@ -89,9 +104,15 @@ SELECT a.pub_key, a.created_at, acc.origin_id, + acc.master_fingerprint, w.is_watch_only AS wallet_is_watch_only, (s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key, - (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script + (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script, + exists( + SELECT 1 + FROM utxos AS u + WHERE u.address_id = a.id + ) AS is_used FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id INNER JOIN wallets AS w ON a.wallet_id = w.id diff --git a/wallet/internal/sql/pg/sqlc/accounts.sql.go b/wallet/internal/sql/pg/sqlc/accounts.sql.go index 4a5f2044ef..06ebd2361f 100644 --- a/wallet/internal/sql/pg/sqlc/accounts.sql.go +++ b/wallet/internal/sql/pg/sqlc/accounts.sql.go @@ -323,6 +323,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -354,6 +356,8 @@ type GetAccountByScopeAndNameRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int16 + ExternalTypeID int16 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -375,6 +379,8 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -395,6 +401,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -426,6 +434,8 @@ type GetAccountByScopeAndNumberRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int16 + ExternalTypeID int16 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -447,6 +457,8 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -467,6 +479,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -504,6 +518,8 @@ type GetAccountByWalletScopeAndNameRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int16 + ExternalTypeID int16 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -530,6 +546,8 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -550,6 +568,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -587,6 +607,8 @@ type GetAccountByWalletScopeAndNumberRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int16 + ExternalTypeID int16 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -613,6 +635,8 @@ func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetA &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -634,6 +658,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -658,6 +684,8 @@ type GetAccountPropsByIdRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int16 + ExternalTypeID int16 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -677,6 +705,8 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -726,6 +756,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -753,6 +785,8 @@ type ListAccountsByScopeRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int16 + ExternalTypeID int16 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -781,6 +815,8 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -811,6 +847,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -838,6 +876,8 @@ type ListAccountsByWalletRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int16 + ExternalTypeID int16 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -866,6 +906,8 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -896,6 +938,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -928,6 +972,8 @@ type ListAccountsByWalletAndNameRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int16 + ExternalTypeID int16 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -956,6 +1002,8 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -986,6 +1034,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -1022,6 +1072,8 @@ type ListAccountsByWalletScopeRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int16 + ExternalTypeID int16 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -1050,6 +1102,8 @@ func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, diff --git a/wallet/internal/sql/pg/sqlc/addresses.sql.go b/wallet/internal/sql/pg/sqlc/addresses.sql.go index 0f4016d074..46f45450ed 100644 --- a/wallet/internal/sql/pg/sqlc/addresses.sql.go +++ b/wallet/internal/sql/pg/sqlc/addresses.sql.go @@ -103,6 +103,10 @@ const GetAddressByScriptPubKey = `-- name: GetAddressByScriptPubKey :one SELECT a.id, a.account_id, + acc.account_number, + acc.account_name, + ks.purpose, + ks.coin_type, a.type_id, a.address_branch, a.address_index, @@ -110,11 +114,18 @@ SELECT a.pub_key, a.created_at, acc.origin_id, + acc.master_fingerprint, w.is_watch_only AS wallet_is_watch_only, (s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key, - (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script + (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script, + exists( + SELECT 1 + FROM utxos AS u + WHERE u.address_id = a.id + ) AS is_used FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id INNER JOIN wallets AS w ON a.wallet_id = w.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE a.script_pub_key = $1 AND a.wallet_id = $2 @@ -128,6 +139,10 @@ type GetAddressByScriptPubKeyParams struct { type GetAddressByScriptPubKeyRow struct { ID int64 AccountID int64 + AccountNumber sql.NullInt64 + AccountName string + Purpose int64 + CoinType int64 TypeID int16 AddressBranch sql.NullInt16 AddressIndex sql.NullInt64 @@ -135,9 +150,11 @@ type GetAddressByScriptPubKeyRow struct { PubKey []byte CreatedAt time.Time OriginID int16 + MasterFingerprint sql.NullInt64 WalletIsWatchOnly bool HasPrivateKey bool HasScript bool + IsUsed bool } // Retrieves an address by its script pubkey and account wallet. @@ -147,6 +164,10 @@ func (q *Queries) GetAddressByScriptPubKey(ctx context.Context, arg GetAddressBy err := row.Scan( &i.ID, &i.AccountID, + &i.AccountNumber, + &i.AccountName, + &i.Purpose, + &i.CoinType, &i.TypeID, &i.AddressBranch, &i.AddressIndex, @@ -154,9 +175,11 @@ func (q *Queries) GetAddressByScriptPubKey(ctx context.Context, arg GetAddressBy &i.PubKey, &i.CreatedAt, &i.OriginID, + &i.MasterFingerprint, &i.WalletIsWatchOnly, &i.HasPrivateKey, &i.HasScript, + &i.IsUsed, ) return i, err } @@ -221,6 +244,10 @@ const ListAddressesByAccount = `-- name: ListAddressesByAccount :many SELECT a.id, a.account_id, + acc.account_number, + acc.account_name, + ks.purpose, + ks.coin_type, a.type_id, a.address_branch, a.address_index, @@ -228,9 +255,15 @@ SELECT a.pub_key, a.created_at, acc.origin_id, + acc.master_fingerprint, w.is_watch_only AS wallet_is_watch_only, (s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key, - (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script + (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script, + exists( + SELECT 1 + FROM utxos AS u + WHERE u.address_id = a.id + ) AS is_used FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id INNER JOIN wallets AS w ON a.wallet_id = w.id @@ -265,6 +298,10 @@ type ListAddressesByAccountParams struct { type ListAddressesByAccountRow struct { ID int64 AccountID int64 + AccountNumber sql.NullInt64 + AccountName string + Purpose int64 + CoinType int64 TypeID int16 AddressBranch sql.NullInt16 AddressIndex sql.NullInt64 @@ -272,9 +309,11 @@ type ListAddressesByAccountRow struct { PubKey []byte CreatedAt time.Time OriginID int16 + MasterFingerprint sql.NullInt64 WalletIsWatchOnly bool HasPrivateKey bool HasScript bool + IsUsed bool } // Lists addresses for an account identified by wallet_id, key scope @@ -300,6 +339,10 @@ func (q *Queries) ListAddressesByAccount(ctx context.Context, arg ListAddressesB if err := rows.Scan( &i.ID, &i.AccountID, + &i.AccountNumber, + &i.AccountName, + &i.Purpose, + &i.CoinType, &i.TypeID, &i.AddressBranch, &i.AddressIndex, @@ -307,9 +350,11 @@ func (q *Queries) ListAddressesByAccount(ctx context.Context, arg ListAddressesB &i.PubKey, &i.CreatedAt, &i.OriginID, + &i.MasterFingerprint, &i.WalletIsWatchOnly, &i.HasPrivateKey, &i.HasScript, + &i.IsUsed, ); err != nil { return nil, err } diff --git a/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql b/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql index d5b24b4b80..b678702c6a 100644 --- a/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql +++ b/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql @@ -1,6 +1,13 @@ -- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). -- This ensures migration tracking stays accurate and fails loudly if run twice. +-- This table intentionally does NOT include a `used` column. +-- An address's used-ness is derived from the utxos table +-- (EXISTS(SELECT 1 FROM utxos WHERE address_id = ?)). The derivation +-- is monotonic because utxo rows are preserved through reorgs via +-- tx_status soft-delete (see ADR 0006) and ON DELETE RESTRICT. See +-- ADR 0011 for the full design rationale. +-- -- Addresses table stores all addresses under each account. Addresses can be -- either HD-derived (following BIP32/BIP44 derivation paths) or imported from -- external sources (e.g., watch-only addresses, hardware wallet addresses). diff --git a/wallet/internal/sql/sqlite/queries/accounts.sql b/wallet/internal/sql/sqlite/queries/accounts.sql index 2d0a150bd4..73e2c3bae9 100644 --- a/wallet/internal/sql/sqlite/queries/accounts.sql +++ b/wallet/internal/sql/sqlite/queries/accounts.sql @@ -66,6 +66,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -93,6 +95,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -120,6 +124,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -151,6 +157,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -183,6 +191,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -208,6 +218,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -237,6 +249,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -269,6 +283,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -298,6 +314,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, diff --git a/wallet/internal/sql/sqlite/queries/addresses.sql b/wallet/internal/sql/sqlite/queries/addresses.sql index 62f99965d1..cc56eb5d87 100644 --- a/wallet/internal/sql/sqlite/queries/addresses.sql +++ b/wallet/internal/sql/sqlite/queries/addresses.sql @@ -15,6 +15,10 @@ INSERT INTO address_secrets ( SELECT a.id, a.account_id, + acc.account_number, + acc.account_name, + ks.purpose, + ks.coin_type, a.type_id, a.address_branch, a.address_index, @@ -22,11 +26,20 @@ SELECT a.pub_key, a.created_at, acc.origin_id, + acc.master_fingerprint, w.is_watch_only AS wallet_is_watch_only, + cast( + EXISTS ( + SELECT 1 + FROM utxos AS u + WHERE u.address_id = a.id + ) AS BOOLEAN + ) AS is_used, s.encrypted_priv_key IS NOT NULL AS has_private_key, s.encrypted_script IS NOT NULL AS has_script FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id INNER JOIN wallets AS w ON a.wallet_id = w.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE a.script_pub_key = ? AND a.wallet_id = ?; @@ -82,6 +95,10 @@ RETURNING id, created_at; SELECT a.id, a.account_id, + acc.account_number, + acc.account_name, + ks.purpose, + ks.coin_type, a.type_id, a.address_branch, a.address_index, @@ -89,7 +106,15 @@ SELECT a.pub_key, a.created_at, acc.origin_id, + acc.master_fingerprint, w.is_watch_only AS wallet_is_watch_only, + cast( + EXISTS ( + SELECT 1 + FROM utxos AS u + WHERE u.address_id = a.id + ) AS BOOLEAN + ) AS is_used, s.encrypted_priv_key IS NOT NULL AS has_private_key, s.encrypted_script IS NOT NULL AS has_script FROM addresses AS a diff --git a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go index 54c0c183a1..417adbc89e 100644 --- a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go @@ -333,6 +333,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -364,6 +366,8 @@ type GetAccountByScopeAndNameRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int64 + ExternalTypeID int64 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -385,6 +389,8 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -405,6 +411,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -436,6 +444,8 @@ type GetAccountByScopeAndNumberRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int64 + ExternalTypeID int64 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -457,6 +467,8 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -477,6 +489,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -514,6 +528,8 @@ type GetAccountByWalletScopeAndNameRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int64 + ExternalTypeID int64 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -540,6 +556,8 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -560,6 +578,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -597,6 +617,8 @@ type GetAccountByWalletScopeAndNumberRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int64 + ExternalTypeID int64 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -623,6 +645,8 @@ func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetA &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -644,6 +668,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -668,6 +694,8 @@ type GetAccountPropsByIdRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int64 + ExternalTypeID int64 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -687,6 +715,8 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -736,6 +766,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -763,6 +795,8 @@ type ListAccountsByScopeRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int64 + ExternalTypeID int64 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -791,6 +825,8 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -821,6 +857,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -848,6 +886,8 @@ type ListAccountsByWalletRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int64 + ExternalTypeID int64 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -876,6 +916,8 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -906,6 +948,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -938,6 +982,8 @@ type ListAccountsByWalletAndNameRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int64 + ExternalTypeID int64 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -966,6 +1012,8 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, @@ -996,6 +1044,8 @@ SELECT a.created_at, ks.purpose, ks.coin_type, + ks.internal_type_id, + ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, a.imported_key_count, @@ -1032,6 +1082,8 @@ type ListAccountsByWalletScopeRow struct { CreatedAt time.Time Purpose int64 CoinType int64 + InternalTypeID int64 + ExternalTypeID int64 ExternalKeyCount int64 InternalKeyCount int64 ImportedKeyCount int64 @@ -1060,6 +1112,8 @@ func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, &i.ExternalKeyCount, &i.InternalKeyCount, &i.ImportedKeyCount, diff --git a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go index e20182fcd4..3eec2d6af3 100644 --- a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go @@ -103,6 +103,10 @@ const GetAddressByScriptPubKey = `-- name: GetAddressByScriptPubKey :one SELECT a.id, a.account_id, + acc.account_number, + acc.account_name, + ks.purpose, + ks.coin_type, a.type_id, a.address_branch, a.address_index, @@ -110,11 +114,20 @@ SELECT a.pub_key, a.created_at, acc.origin_id, + acc.master_fingerprint, w.is_watch_only AS wallet_is_watch_only, + cast( + EXISTS ( + SELECT 1 + FROM utxos AS u + WHERE u.address_id = a.id + ) AS BOOLEAN + ) AS is_used, s.encrypted_priv_key IS NOT NULL AS has_private_key, s.encrypted_script IS NOT NULL AS has_script FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id INNER JOIN wallets AS w ON a.wallet_id = w.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE a.script_pub_key = ? AND a.wallet_id = ? @@ -128,6 +141,10 @@ type GetAddressByScriptPubKeyParams struct { type GetAddressByScriptPubKeyRow struct { ID int64 AccountID int64 + AccountNumber sql.NullInt64 + AccountName string + Purpose int64 + CoinType int64 TypeID int64 AddressBranch sql.NullInt64 AddressIndex sql.NullInt64 @@ -135,7 +152,9 @@ type GetAddressByScriptPubKeyRow struct { PubKey []byte CreatedAt time.Time OriginID int64 + MasterFingerprint sql.NullInt64 WalletIsWatchOnly bool + IsUsed bool HasPrivateKey bool HasScript bool } @@ -147,6 +166,10 @@ func (q *Queries) GetAddressByScriptPubKey(ctx context.Context, arg GetAddressBy err := row.Scan( &i.ID, &i.AccountID, + &i.AccountNumber, + &i.AccountName, + &i.Purpose, + &i.CoinType, &i.TypeID, &i.AddressBranch, &i.AddressIndex, @@ -154,7 +177,9 @@ func (q *Queries) GetAddressByScriptPubKey(ctx context.Context, arg GetAddressBy &i.PubKey, &i.CreatedAt, &i.OriginID, + &i.MasterFingerprint, &i.WalletIsWatchOnly, + &i.IsUsed, &i.HasPrivateKey, &i.HasScript, ) @@ -221,6 +246,10 @@ const ListAddressesByAccount = `-- name: ListAddressesByAccount :many SELECT a.id, a.account_id, + acc.account_number, + acc.account_name, + ks.purpose, + ks.coin_type, a.type_id, a.address_branch, a.address_index, @@ -228,7 +257,15 @@ SELECT a.pub_key, a.created_at, acc.origin_id, + acc.master_fingerprint, w.is_watch_only AS wallet_is_watch_only, + cast( + EXISTS ( + SELECT 1 + FROM utxos AS u + WHERE u.address_id = a.id + ) AS BOOLEAN + ) AS is_used, s.encrypted_priv_key IS NOT NULL AS has_private_key, s.encrypted_script IS NOT NULL AS has_script FROM addresses AS a @@ -265,6 +302,10 @@ type ListAddressesByAccountParams struct { type ListAddressesByAccountRow struct { ID int64 AccountID int64 + AccountNumber sql.NullInt64 + AccountName string + Purpose int64 + CoinType int64 TypeID int64 AddressBranch sql.NullInt64 AddressIndex sql.NullInt64 @@ -272,7 +313,9 @@ type ListAddressesByAccountRow struct { PubKey []byte CreatedAt time.Time OriginID int64 + MasterFingerprint sql.NullInt64 WalletIsWatchOnly bool + IsUsed bool HasPrivateKey bool HasScript bool } @@ -300,6 +343,10 @@ func (q *Queries) ListAddressesByAccount(ctx context.Context, arg ListAddressesB if err := rows.Scan( &i.ID, &i.AccountID, + &i.AccountNumber, + &i.AccountName, + &i.Purpose, + &i.CoinType, &i.TypeID, &i.AddressBranch, &i.AddressIndex, @@ -307,7 +354,9 @@ func (q *Queries) ListAddressesByAccount(ctx context.Context, arg ListAddressesB &i.PubKey, &i.CreatedAt, &i.OriginID, + &i.MasterFingerprint, &i.WalletIsWatchOnly, + &i.IsUsed, &i.HasPrivateKey, &i.HasScript, ); err != nil { diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 69325dd3b5..bec690d6c9 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -195,10 +195,9 @@ func (m *mockStore) RenameAccount(ctx context.Context, // NewDerivedAddress implements the db.AddressStore interface. func (m *mockStore) NewDerivedAddress(ctx context.Context, - params db.NewDerivedAddressParams, - deriveFn db.AddressDerivationFunc) (*db.AddressInfo, error) { + params db.NewDerivedAddressParams) (*db.AddressInfo, error) { - args := m.Called(ctx, params, deriveFn) + args := m.Called(ctx, params) if args.Get(0) == nil { return nil, args.Error(1) }