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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions docs/developer/adr/0011-no-addresses-used-column.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/developer/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 19 additions & 0 deletions waddrmgr/address.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Comment thread
yyforyongyu marked this conversation as resolved.

// 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
Expand Down
78 changes: 61 additions & 17 deletions waddrmgr/tlv.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package waddrmgr

import (
"bytes"
"errors"
"fmt"
"io"

Expand All @@ -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
Expand All @@ -22,32 +35,25 @@ 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)
tlvRecords := []tlv.Record{
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 {
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions wallet/internal/db/accounts_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading