Skip to content

Commit b80a2a4

Browse files
committed
db: derive IsUsed from utxos table on SQL (ADR 0011)
The earlier draft of this commit added a `used BOOL` column to the `addresses` table plus a monotonic-clear trigger. Review re-examined the schema after PR #1232 + ADR 0006 landed: the `transactions` table soft-deletes via `tx_status` and the `utxos` table holds its references via ON DELETE RESTRICT. Reorged transactions and their utxos stay in the database; only an explicit pruning operation removes them. Therefore, on SQL backends, "is this address ever used?" can be answered monotonically with EXISTS(SELECT 1 FROM utxos WHERE address_id = ?) without persisting a separate column. This commit: * Adds ADR 0011 capturing the decision and the asymmetric backend implementation (SQL derived; kvdb keeps waddrmgr's legacy sticky bit because wtxmgr deletes credit records on reorg). * Annotates `000006_addresses.up.sql` with a schema-level pointer so a future contributor reaching for the column finds the ADR. * Threads `IsUsed bool` through `db.AddressInfo` and the address- read queries via the EXISTS derivation. * Makes the pg / sqlite `MarkAddressUsed` adapters no-ops: the wallet's normal "record tx" path already inserts the utxos row that the derivation reads. The interface method stays on `db.AddressStore` for backend symmetry; kvdb still uses it. Future kvdb-side adapter changes for IsUsed/MarkAddressUsed land on top of this contract via the `addresses-used-flag` side branch.
1 parent d4b774f commit b80a2a4

15 files changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# ADR 0011: No `used` Column on the Addresses Table
2+
3+
## 1. Context
4+
5+
The wallet needs to answer "has this address ever appeared in a
6+
transaction the wallet has seen?" — a monotonic property used
7+
by the unused-address scan to enforce privacy (never re-offer a
8+
previously-published address).
9+
10+
In the legacy kvdb backend this is a sticky bit on each
11+
managed address (`waddrmgr.ManagedAddress.Used`). It is set
12+
when the wallet observes the address in a tx and never cleared,
13+
even when the underlying credit record is rolled back by a
14+
reorg (wtxmgr deletes credit records on rollback, so the bit
15+
is the wallet's only durable record).
16+
17+
When the SQL backend was first sketched in PR #1162, an
18+
equivalent boolean column was proposed on `addresses`. Review
19+
collapsed it on the grounds of "two sources of truth": the
20+
utxos table already records whether an address has ever
21+
received a credit, so a flag on `addresses` duplicates state.
22+
The conclusion in #1162 was to derive used-ness from the
23+
`utxos` table at read time and drop the column. Issue #1167
24+
captures the orthogonal "unbroadcast tx" privacy gap that
25+
neither approach solves.
26+
27+
A later session reintroduced the column under
28+
`prep-address-manager-store` (PR #1237) along with a trigger
29+
preventing the bit from clearing. The motivation was that the
30+
wallet's unused-address scan needs *monotonic* used-ness —
31+
which the derived query would lose if a reorg deleted the
32+
funding row.
33+
34+
Re-examination of the SQL schema after PR #1232 + ADR 0006
35+
landed makes the picture clear: under the new SQL design, the
36+
reorg-delete concern does not apply.
37+
38+
- `transactions.block_height INTEGER REFERENCES blocks
39+
(block_height) ON DELETE SET NULL` — a reorged tx becomes
40+
unconfirmed; the **row stays**. `tx_status` carries the
41+
validity state as soft deletion.
42+
- `utxos.tx_id BIGINT NOT NULL REFERENCES transactions (id) ON
43+
DELETE RESTRICT` — the utxo row cannot be deleted while its
44+
creating transaction exists.
45+
- `utxos.spent_by_tx_id BIGINT REFERENCES transactions (id) ON
46+
DELETE RESTRICT` — same protection for spent UTXOs.
47+
- Physical removal of records is an explicit pruning operation
48+
the wallet does not perform in normal flows.
49+
50+
Therefore, on the SQL backend:
51+
52+
```sql
53+
SELECT EXISTS(SELECT 1 FROM utxos WHERE address_id = ?)
54+
```
55+
56+
is **monotonic by construction** through reorgs, replaces,
57+
orphanings, and ordinary operation. The flag column on
58+
`addresses` is genuinely redundant on SQL.
59+
60+
## 2. Decision
61+
62+
The SQL backend (`pg`, `sqlite`) does NOT persist a `used`
63+
column on `addresses`.
64+
65+
- `db.Store.IsUsed` on SQL backends is answered via the derived
66+
EXISTS query above (projected as part of address-read queries
67+
for `GetAddress`, `ListAddresses`, etc.).
68+
- `db.Store.MarkAddressUsed` is a **no-op** on SQL backends.
69+
The wallet's normal "record observed tx" path implicitly
70+
marks the address used because it inserts the `utxos` row
71+
that the derived query reads.
72+
- The kvdb backend continues to use waddrmgr's legacy `Used()`
73+
flag because wtxmgr deletes credit records on reorg and
74+
cannot provide the monotonic guarantee without a separate
75+
bit. `db.Store.MarkAddressUsed` on kvdb calls
76+
`addrStore.MarkUsed(...)` as today.
77+
78+
The `db.AddressInfo.IsUsed` contract field remains. The two
79+
adapters populate it from different sources but with the same
80+
semantics from the wallet's perspective.
81+
82+
## 3. Consequences
83+
84+
### Pros
85+
86+
- Single source of truth on SQL (utxos table), removing the
87+
drift risk of two booleans encoding the same fact.
88+
- One fewer column, one fewer migration, one fewer trigger,
89+
one fewer sqlc query. Smaller surface, less migration churn.
90+
- The contract is uniform — wallet code calls
91+
`w.store.IsUsed(...)` and `w.store.MarkAddressUsed(...)`
92+
regardless of backend.
93+
94+
### Cons
95+
96+
- The SQL adapter pays a per-row EXISTS sub-query cost on
97+
address reads. With `idx_utxos_by_address` (defined in
98+
`000008_utxos.up.sql`) the cost is a bounded index lookup,
99+
negligible for `GetAddress`-style reads and batchable via
100+
LEFT JOIN for `ListAddresses`-style scans.
101+
- The asymmetric backend implementation (derived on SQL, flag
102+
on kvdb) is one more thing for a future contributor to
103+
understand. Mitigated by:
104+
1. A schema-level pointer comment in
105+
`000006_addresses.up.sql` next to where someone would
106+
consider adding the column.
107+
2. Doc comments on `db.Store.IsUsed` and
108+
`db.Store.MarkAddressUsed` that explain the asymmetric
109+
implementation and point here.
110+
111+
### Orthogonal: the unbroadcast-tx gap
112+
113+
Neither design closes the case where a user constructs (but
114+
does not broadcast) a tx referencing a fresh address. The
115+
wallet sees no utxos record and no flag set, so the address
116+
remains "unused" and can be re-offered. This is issue #1167
117+
and is independent of where used-ness is stored.
118+
119+
### Future: if kvdb ever survives reorgs
120+
121+
If wtxmgr's destructive-rollback behavior is ever changed (so
122+
credit records survive reorg the way SQL records do), the
123+
kvdb-side flag becomes redundant for the same reason. At that
124+
point the `MarkAddressUsed` contract method could be retired
125+
entirely. That is out of scope here and would be a future ADR.
126+
127+
## 4. Implementation notes
128+
129+
- Migration `000011_addresses_used.up.sql` and its down-sql
130+
variant are deleted from both `pg` and `sqlite` migration
131+
directories.
132+
- The sqlc `MarkAddressUsed` query is removed from
133+
`wallet/internal/sql/pg/queries/addresses.sql` and the
134+
sqlite mirror.
135+
- Address-read queries project `is_used` via
136+
`EXISTS(SELECT 1 FROM utxos AS u WHERE u.address_id = a.id)`
137+
instead of `a.used`.
138+
- pg/sqlite adapter `Store.MarkAddressUsed` returns `nil`; doc
139+
comment cites this ADR.
140+
- kvdb adapter unchanged.
141+
- Wallet code (the `addresses-used-flag` side branch) keeps
142+
the contract-level routing.
143+
144+
## 5. References
145+
146+
- PR #1162 discussion that first removed the flag
147+
(`wallet/internal/db/data_types.go`,
148+
`wallet/internal/db/migrations/postgres/000006_addresses.up.sql`
149+
threads on the merged PR).
150+
- PR #1237 thread that reopened the question.
151+
- ADR 0006 — the soft-delete / ON DELETE RESTRICT schema
152+
decisions that make the SQL derivation monotonic.
153+
- Issue #1167 — orthogonal unbroadcast-tx privacy gap.

docs/developer/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ ADRs serve as a historical log of important design choices, providing context fo
1616
- [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) - Defines a modular integration test framework for chain and database backend permutations.
1717
- [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.
1818
- [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.
19+
- [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.

wallet/internal/db/addresses_common.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,12 @@ type AddressInfoRow[TypeID, OriginIDType any] struct {
220220
// PubKey is the public key. Zero value for derived addresses.
221221
PubKey []byte
222222

223+
// IsUsed is the monotonic on-chain-usage flag persisted with the
224+
// address row. The address store keeps this in sync with the legacy
225+
// MarkUsed semantics so unused-address scans never re-derive a key
226+
// that briefly appeared in a confirmed transaction.
227+
IsUsed bool
228+
223229
// IDToAddrType converts TypeID to AddressType with validation.
224230
IDToAddrType func(TypeID) (AddressType, error)
225231

@@ -521,6 +527,7 @@ func AddressRowToInfo[TypeID, OriginIDType any](
521527
PubKey: row.PubKey,
522528
HasScript: row.HasScript,
523529
IsWatchOnly: isWatchOnly,
530+
IsUsed: row.IsUsed,
524531
}, nil
525532
}
526533

wallet/internal/db/data_types.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,18 @@ type AddressInfo struct {
652652
// IsWatchOnly indicates whether the address belongs to a watch-only
653653
// wallet or does not have private keys.
654654
IsWatchOnly bool
655+
656+
// IsUsed reports whether the address has ever been associated with
657+
// an on-chain transaction the wallet has observed. The flag is
658+
// monotonic from the wallet's perspective and preserves privacy by
659+
// keeping the address out of unused-address scans even after a
660+
// reorg.
661+
//
662+
// Backends populate this field from different sources (see
663+
// ADR 0011): SQL backends derive it from the utxos table at read
664+
// time; the kvdb backend reads waddrmgr's legacy sticky `Used`
665+
// bit.
666+
IsUsed bool
655667
}
656668

657669
// AddressSecret contains sensitive encrypted material for an address.
@@ -1283,3 +1295,20 @@ func (p BalanceParams) Validate() error {
12831295

12841296
// LockID represents a unique context-specific ID assigned to an output lock.
12851297
type LockID [32]byte
1298+
1299+
// MarkAddressUsedParams identifies a single address that should be flagged as
1300+
// having been observed in an on-chain transaction. The Store treats this as a
1301+
// monotonic write: setting the flag to true is permanent. Callers may re-issue
1302+
// the request safely; the operation is idempotent.
1303+
type MarkAddressUsedParams struct {
1304+
// WalletID scopes the lookup to one wallet so script_pub_key collisions
1305+
// across wallets cannot cross-flag rows.
1306+
//
1307+
// NOTE: uint32 is used to ensure compatibility with standard SQL
1308+
// databases (signed 64-bit integers).
1309+
WalletID uint32
1310+
1311+
// ScriptPubKey identifies the address row by its plaintext locking
1312+
// script. The wallets table guarantees uniqueness within a wallet.
1313+
ScriptPubKey []byte
1314+
}

wallet/internal/db/interface.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,23 @@ type AddressStore interface {
328328
// GetAddressType returns the AddressTypeInfo associated with the given
329329
// address type identifier. An error is returned if the type is unknown.
330330
GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error)
331+
332+
// MarkAddressUsed marks the address as having appeared in a wallet-
333+
// observed transaction. Backend semantics are asymmetric (see
334+
// ADR 0011):
335+
//
336+
// - SQL backends (pg, sqlite): no-op. Used-ness is derived from
337+
// the utxos table at read time; the wallet's normal "record tx"
338+
// path inserts the utxos row that the derivation reads.
339+
//
340+
// - kvdb backend: flips waddrmgr's legacy sticky `Used` bit
341+
// because wtxmgr deletes credit records on reorg and cannot
342+
// provide a monotonic guarantee on its own.
343+
//
344+
// In both cases the operation is idempotent. Calls for an unknown
345+
// (WalletID, ScriptPubKey) pair return nil so chain reorgs that
346+
// surface unowned scripts do not propagate failures.
347+
MarkAddressUsed(ctx context.Context, params MarkAddressUsedParams) error
331348
}
332349

333350
// TxStore defines the database actions for managing transaction records.

wallet/internal/db/kvdb/addressstore.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,10 @@ func (s *Store) GetAddressType(ctx context.Context,
6868

6969
return db.AddressTypeInfo{}, notImplemented(ctx, "GetAddressType")
7070
}
71+
72+
// MarkAddressUsed is not yet implemented for kvdb.
73+
func (s *Store) MarkAddressUsed(ctx context.Context,
74+
_ db.MarkAddressUsedParams) error {
75+
76+
return notImplemented(ctx, "MarkAddressUsed")
77+
}

wallet/internal/db/pg/addresses.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@ func addressRowToInfo[T addressInfoRow](ctx context.Context, q *sqlc.Queries,
410410
AddressIndex: base.AddressIndex,
411411
ScriptPubKey: base.ScriptPubKey,
412412
PubKey: base.PubKey,
413+
IsUsed: base.IsUsed,
413414
IDToAddrType: db.IDToAddressType[int16],
414415
IDToOrigin: db.IDToOrigin[int16],
415416
})
@@ -471,3 +472,14 @@ func buildAddressPageParams(
471472

472473
return params
473474
}
475+
476+
// MarkAddressUsed is a no-op on SQL backends. Address used-ness is
477+
// derived from the utxos table at read time, not stored as a flag, so
478+
// no separate mark step is needed — the wallet's normal "record tx"
479+
// path inserts the utxos row that the IsUsed derivation reads. See
480+
// ADR 0011.
481+
func (s *Store) MarkAddressUsed(_ context.Context,
482+
_ db.MarkAddressUsedParams) error {
483+
484+
return nil
485+
}

wallet/internal/db/sqlite/addresses.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ func addressRowToInfo[T addressInfoRow](ctx context.Context, q *sqlc.Queries,
404404
AddressIndex: base.AddressIndex,
405405
ScriptPubKey: base.ScriptPubKey,
406406
PubKey: base.PubKey,
407+
IsUsed: base.IsUsed == 1,
407408
IDToAddrType: db.IDToAddressType[int64],
408409
IDToOrigin: db.IDToOrigin[int64],
409410
})
@@ -462,3 +463,14 @@ func buildAddressPageParams(
462463

463464
return params
464465
}
466+
467+
// MarkAddressUsed is a no-op on SQL backends. Address used-ness is
468+
// derived from the utxos table at read time, not stored as a flag, so
469+
// no separate mark step is needed — the wallet's normal "record tx"
470+
// path inserts the utxos row that the IsUsed derivation reads. See
471+
// ADR 0011.
472+
func (s *Store) MarkAddressUsed(_ context.Context,
473+
_ db.MarkAddressUsedParams) error {
474+
475+
return nil
476+
}

wallet/internal/sql/pg/migrations/000006_addresses.up.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS").
22
-- This ensures migration tracking stays accurate and fails loudly if run twice.
33

4+
-- This table intentionally does NOT include a `used` column.
5+
-- An address's used-ness is derived from the utxos table
6+
-- (EXISTS(SELECT 1 FROM utxos WHERE address_id = ?)). The derivation
7+
-- is monotonic because utxo rows are preserved through reorgs via
8+
-- tx_status soft-delete (see ADR 0006) and ON DELETE RESTRICT. See
9+
-- ADR 0011 for the full design rationale.
10+
--
411
-- Addresses table stores all addresses under each account. Addresses can be
512
-- either HD-derived (following BIP32/BIP44 derivation paths) or imported from
613
-- external sources (e.g., watch-only addresses, hardware wallet addresses).

wallet/internal/sql/pg/queries/addresses.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ SELECT
2121
a.script_pub_key,
2222
a.pub_key,
2323
a.created_at,
24+
EXISTS(SELECT 1 FROM utxos AS u WHERE u.address_id = a.id) AS is_used,
2425
acc.origin_id,
2526
w.is_watch_only AS wallet_is_watch_only,
2627
(s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key,
@@ -88,6 +89,7 @@ SELECT
8889
a.script_pub_key,
8990
a.pub_key,
9091
a.created_at,
92+
EXISTS(SELECT 1 FROM utxos AS u WHERE u.address_id = a.id) AS is_used,
9193
acc.origin_id,
9294
w.is_watch_only AS wallet_is_watch_only,
9395
(s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key,
@@ -112,3 +114,4 @@ WHERE
112114
)
113115
ORDER BY a.id
114116
LIMIT sqlc.arg('page_limit')::BIGINT;
117+

0 commit comments

Comments
 (0)