diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b069e7392..a97c44f18 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -58,7 +58,7 @@ package may import from a higher layer. | [`serverconn/mailboxpull`](serverconn/mailboxpull/) | Shared exponential-backoff retry primitives for mailbox pull loops (used by serverconn ingress and SDK swap consumers) | | [`rpcauth`](rpcauth/) | Shared macaroon and TLS helpers securing gRPC/REST connections | | [`metrics`](metrics/) | Prometheus instrumentation namespaced under `waved_`: event-driven counter actor pool plus a scrape-time `SystemCollector` for live gauges, and an opt-in `/metrics` HTTP server | -| [`internal/sqlbase`](internal/sqlbase/) | `walletdb`-compatible key/value backend over `database/sql` (js/wasm walletdb storage for `lwwallet` browser builds) | +| [`internal/sqlbase`](internal/sqlbase/) | `walletdb`-compatible key/value backend over `database/sql` (SQL walletdb storage for `lwwallet`) | | [`internal/wasmhost`](internal/wasmhost/) | js/wasm host detection (browser vs Node) and the durable SQLite VFS name that follows from it; imported by `db`, `lwwallet`, and `cmd/wavewalletdk-wasm` | ### Layer 3: Application & Orchestration diff --git a/cmd/waved/main.go b/cmd/waved/main.go index b914d3973..307f02c44 100644 --- a/cmd/waved/main.go +++ b/cmd/waved/main.go @@ -128,42 +128,7 @@ func newRootCmd() *cobra.Command { registerArkServerFlags(f, cfg) - // Wallet backend flags. - f.String( - "wallet.type", cfg.Wallet.Type, - "wallet backend type (lnd, lwwallet, btcwallet)", - ) - f.String( - "wallet.esploraurl", cfg.Wallet.EsploraURL, - "esplora REST API URL (required for lwwallet)", - ) - f.String( - "wallet.feeurl", cfg.Wallet.FeeURL, - "fee-estimate JSON endpoint URL (required for btcwallet)", - ) - f.String( - "wallet.btcwallet_blockheaderssource", - cfg.Wallet.BtcwBlockSource, - "block header import source for btcwallet fast sync", - ) - f.String( - "wallet.btcwallet_filterheaderssource", - cfg.Wallet.BtcwFilterSource, - "filter header import source for btcwallet fast sync", - ) - f.Duration( - "wallet.pollinterval", cfg.Wallet.PollInterval, - "chain poll interval for lwwallet backend", - ) - f.Uint32( - "wallet.recoverywindow", cfg.Wallet.RecoveryWindow, - "address recovery look-ahead window for lwwallet", - ) - f.String( - "wallet.password_file", cfg.Wallet.PasswordFile, "path to "+ - "file containing wallet password for auto-unlock "+ - "at startup (lwwallet/btcwallet)", - ) + registerWalletFlags(f, cfg) registerBitcoindFlags(f) @@ -362,6 +327,50 @@ func registerDaemonRPCFlags(f *pflag.FlagSet, cfg *waved.Config) { ) } +// registerWalletFlags registers the flags of the daemon's wallet +// backends. +func registerWalletFlags(f *pflag.FlagSet, cfg *waved.Config) { + f.String( + "wallet.type", cfg.Wallet.Type, + "wallet backend type (lnd, lwwallet, btcwallet)", + ) + f.String( + "wallet.esploraurl", cfg.Wallet.EsploraURL, + "esplora REST API URL (required for lwwallet)", + ) + f.String( + "wallet.dbbackend", cfg.Wallet.DBBackend, + "wallet database backend for lwwallet (bolt, sqlite)", + ) + f.String( + "wallet.feeurl", cfg.Wallet.FeeURL, + "fee-estimate JSON endpoint URL (required for btcwallet)", + ) + f.String( + "wallet.btcwallet_blockheaderssource", + cfg.Wallet.BtcwBlockSource, + "block header import source for btcwallet fast sync", + ) + f.String( + "wallet.btcwallet_filterheaderssource", + cfg.Wallet.BtcwFilterSource, + "filter header import source for btcwallet fast sync", + ) + f.Duration( + "wallet.pollinterval", cfg.Wallet.PollInterval, + "chain poll interval for lwwallet backend", + ) + f.Uint32( + "wallet.recoverywindow", cfg.Wallet.RecoveryWindow, + "address recovery look-ahead window for lwwallet", + ) + f.String( + "wallet.password_file", cfg.Wallet.PasswordFile, "path to "+ + "file containing wallet password for auto-unlock "+ + "at startup (lwwallet/btcwallet)", + ) +} + // registerArkServerFlags registers the daemon's outbound Ark operator flags. func registerArkServerFlags(f *pflag.FlagSet, cfg *waved.Config) { f.String( diff --git a/docs/daemon_cli_guide.md b/docs/daemon_cli_guide.md index 084a0f403..0f6041a1b 100644 --- a/docs/daemon_cli_guide.md +++ b/docs/daemon_cli_guide.md @@ -130,6 +130,7 @@ waved \ | `--wallet.btcwallet_filterheaderssource` | | Filter header import source for btcwallet fast sync | | `--wallet.pollinterval` | `30s` | Esplora poll interval (lwwallet only) | | `--wallet.recoverywindow` | `100` | Address look-ahead window (lwwallet only) | +| `--wallet.dbbackend` | `bolt` | Wallet database backend (lwwallet only): `bolt` or `sqlite`; fixed once the wallet is created | | `--wallet.password_file` | | Auto-unlock password file path (lwwallet/btcwallet) | | `--lnd.host` | `localhost:10009` | lnd gRPC address | | `--lnd.tlspath` | | Path to lnd TLS certificate | diff --git a/internal/sqlbase/AGENTS.md b/internal/sqlbase/AGENTS.md index 4aa373ca3..0cf9d4d48 100644 --- a/internal/sqlbase/AGENTS.md +++ b/internal/sqlbase/AGENTS.md @@ -2,11 +2,12 @@ ## Purpose -A `walletdb`-compatible key/value backend implemented over `database/sql`, -built only for `js && wasm` (every file carries that build tag). It emulates -`btcwallet/walletdb` buckets/cursors on top of a relational table so -`lwwallet` can run the wallet stack in the browser (SQLite via -`go-wasmsqlite`), where the native BoltDB/`kvdb` backends are unavailable. +A `walletdb`-compatible key/value backend implemented over `database/sql`. +It emulates `btcwallet/walletdb` buckets/cursors on top of a relational +table so `lwwallet` can run the wallet stack on any `database/sql` driver +instead of the BoltDB/`kvdb` backends: `go-wasmsqlite` (OPFS) in the +browser, where BoltDB is unavailable at all, and `modernc.org/sqlite` on +native builds. ## Key Types @@ -29,13 +30,10 @@ built only for `js && wasm` (every file carries that build tag). It emulates - **Depends on**: `btcwallet/walletdb` (interface being implemented), `lnd/sqldb` (shared SQL error classification). -- **Depended on by**: `lwwallet` (wasm builds only, via `internal/sqlbase`). +- **Depended on by**: `lwwallet` (all platforms). ## Invariants -- Every file is `//go:build js && wasm`; this package does not build (and - cannot be exercised) on native `GOOS`/`GOARCH` — use - `GOOS=js GOARCH=wasm go build ./internal/sqlbase` or `go doc` to inspect it. - `DefaultNumTxRetries = 50`: `Update`/`View` retry on transaction errors that permit repetition, calling the caller's `reset` before each retry. - `WithTxLevelLock` serializes all read-write transactions through a single diff --git a/internal/sqlbase/CLAUDE.md b/internal/sqlbase/CLAUDE.md index 4aa373ca3..0cf9d4d48 100644 --- a/internal/sqlbase/CLAUDE.md +++ b/internal/sqlbase/CLAUDE.md @@ -2,11 +2,12 @@ ## Purpose -A `walletdb`-compatible key/value backend implemented over `database/sql`, -built only for `js && wasm` (every file carries that build tag). It emulates -`btcwallet/walletdb` buckets/cursors on top of a relational table so -`lwwallet` can run the wallet stack in the browser (SQLite via -`go-wasmsqlite`), where the native BoltDB/`kvdb` backends are unavailable. +A `walletdb`-compatible key/value backend implemented over `database/sql`. +It emulates `btcwallet/walletdb` buckets/cursors on top of a relational +table so `lwwallet` can run the wallet stack on any `database/sql` driver +instead of the BoltDB/`kvdb` backends: `go-wasmsqlite` (OPFS) in the +browser, where BoltDB is unavailable at all, and `modernc.org/sqlite` on +native builds. ## Key Types @@ -29,13 +30,10 @@ built only for `js && wasm` (every file carries that build tag). It emulates - **Depends on**: `btcwallet/walletdb` (interface being implemented), `lnd/sqldb` (shared SQL error classification). -- **Depended on by**: `lwwallet` (wasm builds only, via `internal/sqlbase`). +- **Depended on by**: `lwwallet` (all platforms). ## Invariants -- Every file is `//go:build js && wasm`; this package does not build (and - cannot be exercised) on native `GOOS`/`GOARCH` — use - `GOOS=js GOARCH=wasm go build ./internal/sqlbase` or `go doc` to inspect it. - `DefaultNumTxRetries = 50`: `Update`/`View` retry on transaction errors that permit repetition, calling the caller's `reset` before each retry. - `WithTxLevelLock` serializes all read-write transactions through a single diff --git a/internal/sqlbase/db.go b/internal/sqlbase/db.go index 4c0b38a67..05c73422f 100644 --- a/internal/sqlbase/db.go +++ b/internal/sqlbase/db.go @@ -1,5 +1,3 @@ -//go:build js && wasm - package sqlbase import ( @@ -77,6 +75,8 @@ type db struct { // // TODO: This is an anti-pattern that is in place until the kvdb // interface supports a context. + // + //nolint:containedctx ctx context.Context // db is the underlying database connection instance. @@ -163,11 +163,6 @@ func (db *db) getTimeoutCtx() (context.Context, func()) { return context.WithTimeout(db.ctx, db.cfg.Timeout) } -// getPrefixedTableName returns a table name for this prefix (namespace). -func (db *db) getPrefixedTableName(table string) string { - return fmt.Sprintf("%s_%s", db.prefix, table) -} - // catchPanic executes the specified function. If a panic occurs, it is returned // as an error value. func catchPanic(f func() error) (err error) { @@ -208,9 +203,11 @@ func catchPanic(f func() error) (err error) { // expect retries of the f closure (depending on the database backend used), the // reset function will be called before each retry respectively. func (db *db) View(f func(tx walletdb.ReadTx) error, reset func()) error { + // walletdb.ReadWriteTx embeds walletdb.ReadTx, so the read-write + // transaction satisfies the read-only callback as-is. return db.executeTransaction( func(tx walletdb.ReadWriteTx) error { - return f(tx.(walletdb.ReadTx)) + return f(tx) }, reset, true, ) @@ -245,6 +242,7 @@ func (db *db) executeTransaction(f func(tx walletdb.ReadWriteTx) error, } reset() + return catchPanic(func() error { return f(kvTx) }) } diff --git a/internal/sqlbase/db_conn_set.go b/internal/sqlbase/db_conn_set.go index 081b60b0d..2f42b4735 100644 --- a/internal/sqlbase/db_conn_set.go +++ b/internal/sqlbase/db_conn_set.go @@ -1,5 +1,3 @@ -//go:build js && wasm - package sqlbase import ( diff --git a/internal/sqlbase/log.go b/internal/sqlbase/log.go index e31810ea8..7f7194550 100644 --- a/internal/sqlbase/log.go +++ b/internal/sqlbase/log.go @@ -1,5 +1,3 @@ -//go:build js && wasm - package sqlbase import "github.com/btcsuite/btclog/v2" diff --git a/internal/sqlbase/readwrite_bucket.go b/internal/sqlbase/readwrite_bucket.go index c069fe321..c371e3991 100644 --- a/internal/sqlbase/readwrite_bucket.go +++ b/internal/sqlbase/readwrite_bucket.go @@ -1,5 +1,3 @@ -//go:build js && wasm - package sqlbase import ( @@ -84,7 +82,7 @@ func (b *readWriteBucket) Get(key []byte) []byte { err := row.Scan(&value) switch { - case err == sql.ErrNoRows: + case errors.Is(err, sql.ErrNoRows): return nil case err != nil: @@ -137,7 +135,7 @@ func (b *readWriteBucket) NestedReadWriteBucket( err := row.Scan(&id) switch { - case err == sql.ErrNoRows: + case errors.Is(err, sql.ErrNoRows): return nil case err != nil: @@ -173,7 +171,7 @@ func (b *readWriteBucket) CreateBucket(key []byte) (walletdb.ReadWriteBucket, err := row.Scan(&id, &value) switch { - case err == sql.ErrNoRows: + case errors.Is(err, sql.ErrNoRows): case err == nil && value == nil: return nil, walletdb.ErrBucketExists @@ -229,7 +227,7 @@ func (b *readWriteBucket) CreateBucketIfNotExists(key []byte) ( switch { // Bucket does not yet exist, so create it now. Postgres will generate a // bucket id for the new bucket. - case err == sql.ErrNoRows: + case errors.Is(err, sql.ErrNoRows): row, cancel := b.tx.QueryRow( "INSERT INTO "+b.table+" (parent_id, key) "+ "VALUES($1, $2) RETURNING id", @@ -365,7 +363,7 @@ func (b *readWriteBucket) Delete(key []byte) error { err := row.Scan(&dummy) switch { // No bucket exists, proceed to deletion of the key. - case err == sql.ErrNoRows: + case errors.Is(err, sql.ErrNoRows): case err != nil: return err @@ -447,7 +445,7 @@ func (b *readWriteBucket) Sequence() uint64 { err := row.Scan(&seq) switch { - case err == sql.ErrNoRows: + case errors.Is(err, sql.ErrNoRows): return 0 case err != nil: @@ -473,6 +471,13 @@ func (b *readWriteBucket) ForAll(cb func(k, v []byte) error) error { } defer cancel() + // cancel only releases the query's timeout context; the result set + // holds a connection from the pool until it is closed explicitly, + // which rows.Next() only does for us if it is driven to completion. + defer func() { + _ = rows.Close() + }() + for rows.Next() { var key, value []byte @@ -487,5 +492,8 @@ func (b *readWriteBucket) ForAll(cb func(k, v []byte) error) error { } } - return nil + // A row error terminates the loop above just like a fully consumed + // result set does, so without this check a truncated bucket walk is + // indistinguishable from a complete one. + return rows.Err() } diff --git a/internal/sqlbase/readwrite_cursor.go b/internal/sqlbase/readwrite_cursor.go index 02f3fd900..6c346f3f1 100644 --- a/internal/sqlbase/readwrite_cursor.go +++ b/internal/sqlbase/readwrite_cursor.go @@ -1,9 +1,8 @@ -//go:build js && wasm - package sqlbase import ( "database/sql" + "errors" "github.com/btcsuite/btcwallet/walletdb" ) @@ -39,7 +38,7 @@ func (c *readWriteCursor) First() ([]byte, []byte) { err := row.Scan(&key, &value) switch { - case err == sql.ErrNoRows: + case errors.Is(err, sql.ErrNoRows): return nil, nil case err != nil: @@ -69,7 +68,7 @@ func (c *readWriteCursor) Last() ([]byte, []byte) { err := row.Scan(&key, &value) switch { - case err == sql.ErrNoRows: + case errors.Is(err, sql.ErrNoRows): return nil, nil case err != nil: @@ -100,7 +99,7 @@ func (c *readWriteCursor) Next() ([]byte, []byte) { err := row.Scan(&key, &value) switch { - case err == sql.ErrNoRows: + case errors.Is(err, sql.ErrNoRows): return nil, nil case err != nil: @@ -131,7 +130,7 @@ func (c *readWriteCursor) Prev() ([]byte, []byte) { err := row.Scan(&key, &value) switch { - case err == sql.ErrNoRows: + case errors.Is(err, sql.ErrNoRows): return nil, nil case err != nil: @@ -169,7 +168,7 @@ func (c *readWriteCursor) Seek(seek []byte) ([]byte, []byte) { err := row.Scan(&key, &value) switch { - case err == sql.ErrNoRows: + case errors.Is(err, sql.ErrNoRows): return nil, nil case err != nil: @@ -199,7 +198,7 @@ func (c *readWriteCursor) Delete() error { err := row.Scan(&key) switch { - case err == sql.ErrNoRows: + case errors.Is(err, sql.ErrNoRows): return nil case err != nil: diff --git a/internal/sqlbase/readwrite_tx.go b/internal/sqlbase/readwrite_tx.go index d49862752..762823b90 100644 --- a/internal/sqlbase/readwrite_tx.go +++ b/internal/sqlbase/readwrite_tx.go @@ -1,5 +1,3 @@ -//go:build js && wasm - package sqlbase import ( diff --git a/internal/sqlbase/schema.go b/internal/sqlbase/schema.go index 9578365fe..fd61a49ac 100644 --- a/internal/sqlbase/schema.go +++ b/internal/sqlbase/schema.go @@ -1,5 +1,3 @@ -//go:build js && wasm - package sqlbase import ( @@ -20,8 +18,7 @@ func newKVSchemaCreationCmd(table, schema string, finalCmd string ) if schema != "" { - finalCmd = fmt.Sprintf(`CREATE SCHEMA IF NOT EXISTS ` + schema + - `;`) + finalCmd = `CREATE SCHEMA IF NOT EXISTS ` + schema + `;` tableInSchema = fmt.Sprintf("%s.%s", schema, table) } @@ -43,7 +40,7 @@ func newKVSchemaCreationCmd(table, schema string, // // The replacements map can be used to replace any sqlite keywords. // Callers should note that the sqlite keywords are case-sensitive. - finalCmd += fmt.Sprintf(` + finalCmd += ` CREATE TABLE IF NOT EXISTS ` + tableInSchema + ` ( key BLOB NOT NULL, @@ -63,10 +60,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS ` + table + `_up (parent_id, key) WHERE parent_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS ` + table + `_unp ON ` + tableInSchema + ` (key) WHERE parent_id IS NULL; -`) +` for from, to := range replacements { - finalCmd = strings.Replace(finalCmd, from, to, -1) + finalCmd = strings.ReplaceAll(finalCmd, from, to) } return finalCmd diff --git a/lwwallet/AGENTS.md b/lwwallet/AGENTS.md index c7e1e757b..d35aa3cea 100644 --- a/lwwallet/AGENTS.md +++ b/lwwallet/AGENTS.md @@ -80,9 +80,10 @@ base logic with the neutrino-backed `btcwbackend` sibling via the extracted - **Depends on**: `walletcore` (shared HD key mgmt, signing, boarding base — also used by `btcwbackend`), `chainsource` (implements `ChainBackend`), `wallet` (implements `BoardingBackend`), `chainbackends` (typed - `PackageTxError` for package-relay results), and — on `js && wasm` builds - only — `internal/sqlbase` (walletdb-compatible SQL backend) plus - `internal/wasmhost` (which durable SQLite VFS the host offers). + `PackageTxError` for package-relay results), `internal/sqlbase` + (walletdb-compatible SQL backend, used by both SQL wallet stores) and — + on `js && wasm` builds only — `internal/wasmhost` (which durable SQLite + VFS the host offers). - **Depended on by**: `waved` (alternative to LND-backed wallet), `sdk` (embedded-wallet config references). @@ -125,10 +126,33 @@ base logic with the neutrino-backed `btcwbackend` sibling via the extracted - UTXO enumeration queries Esplora directly rather than btcwallet's internal UTXO set, because btcwallet does not credit-mark non-default scope outputs. - `Stop()` explicitly closes btcwallet's internal database to prevent resource - leaks. + leaks. That is not optional for the SQL wallet stores: btcwallet's loader + only closes a database it opened itself, so nothing else would release an + externally supplied handle. - Shutdown and startup rollback cancel `EsploraChainService` before stopping btcwallet so an active recovery can drain. +### Native wallet store (`walletdb_native.go`) + +- `Config.DBBackend` picks the engine btcwallet's wallet database runs on: + `DBBackendBolt` (the default) keeps the classic `wallet.db` BoltDB file, + `DBBackendSQLite` keeps `wallet.sqlite.db` on `modernc.org/sqlite` through + `internal/sqlbase`. Both live under `Config.DBDir`. +- The two backends use different file names on purpose, and `walletDBPath` + refuses to resolve when the other backend's file is present. Switching the + backend of an initialized directory would otherwise look exactly like a + first start, and btcwallet would create a second, empty wallet beside the + existing one. +- `walletExists` returns early when the database file is missing, so probing + a fresh directory does not create the database it is asking about — which + is also what keeps the guard above from tripping on a database that never + held a wallet. +- The SQLite DSN sets `_txlock=immediate`. The wallet reads before it writes + inside one transaction (deriving the next address reads the last-used index, + then bumps it), and a deferred transaction that upgrades to a writer after + another connection committed fails with `SQLITE_BUSY_SNAPSHOT`, which + bypasses the busy handler and is therefore not absorbed by `busy_timeout`. + ### js/wasm wallet store (`walletdb_wasm.go`) - The btcwallet SQL walletdb opens against whichever durable VFS diff --git a/lwwallet/CLAUDE.md b/lwwallet/CLAUDE.md index c7e1e757b..d35aa3cea 100644 --- a/lwwallet/CLAUDE.md +++ b/lwwallet/CLAUDE.md @@ -80,9 +80,10 @@ base logic with the neutrino-backed `btcwbackend` sibling via the extracted - **Depends on**: `walletcore` (shared HD key mgmt, signing, boarding base — also used by `btcwbackend`), `chainsource` (implements `ChainBackend`), `wallet` (implements `BoardingBackend`), `chainbackends` (typed - `PackageTxError` for package-relay results), and — on `js && wasm` builds - only — `internal/sqlbase` (walletdb-compatible SQL backend) plus - `internal/wasmhost` (which durable SQLite VFS the host offers). + `PackageTxError` for package-relay results), `internal/sqlbase` + (walletdb-compatible SQL backend, used by both SQL wallet stores) and — + on `js && wasm` builds only — `internal/wasmhost` (which durable SQLite + VFS the host offers). - **Depended on by**: `waved` (alternative to LND-backed wallet), `sdk` (embedded-wallet config references). @@ -125,10 +126,33 @@ base logic with the neutrino-backed `btcwbackend` sibling via the extracted - UTXO enumeration queries Esplora directly rather than btcwallet's internal UTXO set, because btcwallet does not credit-mark non-default scope outputs. - `Stop()` explicitly closes btcwallet's internal database to prevent resource - leaks. + leaks. That is not optional for the SQL wallet stores: btcwallet's loader + only closes a database it opened itself, so nothing else would release an + externally supplied handle. - Shutdown and startup rollback cancel `EsploraChainService` before stopping btcwallet so an active recovery can drain. +### Native wallet store (`walletdb_native.go`) + +- `Config.DBBackend` picks the engine btcwallet's wallet database runs on: + `DBBackendBolt` (the default) keeps the classic `wallet.db` BoltDB file, + `DBBackendSQLite` keeps `wallet.sqlite.db` on `modernc.org/sqlite` through + `internal/sqlbase`. Both live under `Config.DBDir`. +- The two backends use different file names on purpose, and `walletDBPath` + refuses to resolve when the other backend's file is present. Switching the + backend of an initialized directory would otherwise look exactly like a + first start, and btcwallet would create a second, empty wallet beside the + existing one. +- `walletExists` returns early when the database file is missing, so probing + a fresh directory does not create the database it is asking about — which + is also what keeps the guard above from tripping on a database that never + held a wallet. +- The SQLite DSN sets `_txlock=immediate`. The wallet reads before it writes + inside one transaction (deriving the next address reads the last-used index, + then bumps it), and a deferred transaction that upgrades to a writer after + another connection committed fails with `SQLITE_BUSY_SNAPSHOT`, which + bypasses the busy handler and is therefore not absorbed by `busy_timeout`. + ### js/wasm wallet store (`walletdb_wasm.go`) - The btcwallet SQL walletdb opens against whichever durable VFS diff --git a/lwwallet/config.go b/lwwallet/config.go index a47c9bd50..1791c8226 100644 --- a/lwwallet/config.go +++ b/lwwallet/config.go @@ -56,12 +56,23 @@ type Config struct { // scenarios where previously derived keys must be rediscovered. RecoveryWindow uint32 - // DBDir is the directory for btcwallet's bbolt database. The + // DBDir is the directory for btcwallet's wallet database. The // caller owns the lifecycle of this directory: for tests a temp // directory can be created and cleaned up after the wallet stops, // while production callers may use a persistent path. DBDir string + // DBBackend selects the database engine btcwallet's wallet + // database (seed, key state and transaction store) is kept in, + // one of DBBackendBolt or DBBackendSQLite. An empty value + // resolves to DBBackendBolt. Both backends keep their database + // under DBDir, under a backend-specific file name. + // + // Browser builds ignore the field: there the store is always the + // OPFS-backed SQLite database, because BoltDB cannot be used + // under js/wasm at all. + DBBackend string + // Log is an optional logger for the wallet and all its sub-components // (chain service, chain backend, boarding backend, Esplora client). If // None, the wallet falls back to extracting a logger from context via diff --git a/lwwallet/wallet.go b/lwwallet/wallet.go index 3883d3a32..ce6ded9b5 100644 --- a/lwwallet/wallet.go +++ b/lwwallet/wallet.go @@ -80,10 +80,10 @@ var ErrWalletNotFound = errors.New("no wallet database found") var ErrWalletExists = errors.New("wallet database already exists") // WalletExists reports whether a wallet database has already been -// created for the given configuration. Only ChainParams, RecoveryWindow -// and DBDir are consulted. Callers use this to decide between the -// create (seed) and open (password-only) paths before constructing the -// wallet. +// created for the given configuration. Only ChainParams, RecoveryWindow, +// DBDir and DBBackend are consulted. Callers use this to decide between +// the create (seed) and open (password-only) paths before constructing +// the wallet. func WalletExists(cfg Config) (bool, error) { return walletExists(cfg) } @@ -120,8 +120,8 @@ func checkWalletInvariants(cfg Config) error { // New creates a new lightweight wallet from the given configuration. // The caller must provide a DBDir for btcwallet's wallet database. Native -// builds use that path for btcwallet's bbolt database, while browser builds -// derive a stable OPFS SQLite database name from it. +// builds put the database there under a name chosen by Config.DBBackend, +// while browser builds derive a stable OPFS SQLite database name from it. func New(cfg Config) (*Wallet, error) { // Constructors run before a contextual logger is guaranteed, // so default to a disabled logger when one was not explicitly @@ -185,8 +185,9 @@ func New(cfg Config) (*Wallet, error) { }, blockCache) if err != nil { // On failure the wallet never adopted the loader's - // database handle, so release it here (a no-op natively, - // an OPFS handle close in browser builds). + // database handle, so release it here (a no-op for the + // BoltDB backend, which btcwallet's loader opens and + // closes itself, and a handle close for the SQL ones). loaderCleanup() return nil, fmt.Errorf("create btcwallet: %w", err) @@ -252,8 +253,9 @@ func (w *Wallet) Start() error { // New opened the wallet database, so a failed start must close // it again or a retried unlock deadlocks on the database's - // exclusive lock (bbolt flock natively, EXCLUSIVE OPFS locking - // in browser builds). This matters in particular for a wrong + // exclusive lock (the BoltDB flock, the SQLite write lock, or + // EXCLUSIVE OPFS locking in browser builds). This matters in + // particular for a wrong // wallet passphrase, which surfaces from BtcWallet.Start below. // Appended first so the reverse-order unwind runs it last, after // the subsystems armed below have been rolled back. diff --git a/lwwallet/wallet_lifecycle_test.go b/lwwallet/wallet_lifecycle_test.go index 13a9a4f15..3bc97ff00 100644 --- a/lwwallet/wallet_lifecycle_test.go +++ b/lwwallet/wallet_lifecycle_test.go @@ -49,7 +49,8 @@ func newTestEsplora(t *testing.T) *httptest.Server { } // testWalletConfig returns a lifecycle-test config for the given -// database directory, seed, and password. +// database directory, seed, and password, using the platform's default +// wallet database backend. func testWalletConfig(esploraURL, dbDir string, seed, password []byte) Config { return Config{ Seed: seed, @@ -65,10 +66,24 @@ func testWalletConfig(esploraURL, dbDir string, seed, password []byte) Config { // TestWalletCreateOpenLifecycle verifies the create/open contract: a // seed creates the wallet database under the supplied passphrase, and -// subsequent opens need only the passphrase. +// subsequent opens need only the passphrase. Every supported wallet +// database backend has to satisfy the same contract, so the test runs +// once per backend the platform offers. func TestWalletCreateOpenLifecycle(t *testing.T) { t.Parallel() + for _, backend := range testWalletDBBackends { + t.Run(backend, func(t *testing.T) { + t.Parallel() + + testWalletCreateOpenLifecycle(t, backend) + }) + } +} + +// testWalletCreateOpenLifecycle drives the create/open contract against +// one wallet database backend. +func testWalletCreateOpenLifecycle(t *testing.T, backend string) { esplora := newTestEsplora(t) dbDir := t.TempDir() @@ -78,19 +93,24 @@ func TestWalletCreateOpenLifecycle(t *testing.T) { } password := []byte("lifecycle-password") + walletConfig := func(seed []byte) Config { + cfg := testWalletConfig(esplora.URL, dbDir, seed, password) + cfg.DBBackend = backend + + return cfg + } + // Opening before any wallet exists must fail loudly rather than // silently creating a wallet with a random seed. - _, err := New(testWalletConfig(esplora.URL, dbDir, nil, password)) + _, err := New(walletConfig(nil)) require.ErrorIs(t, err, ErrWalletNotFound) // Create the wallet from the seed. - exists, err := WalletExists( - testWalletConfig(esplora.URL, dbDir, nil, password), - ) + exists, err := WalletExists(walletConfig(nil)) require.NoError(t, err) require.False(t, exists) - w, err := New(testWalletConfig(esplora.URL, dbDir, seed[:], password)) + w, err := New(walletConfig(seed[:])) require.NoError(t, err) require.NoError(t, w.Start()) @@ -106,21 +126,19 @@ func TestWalletCreateOpenLifecycle(t *testing.T) { w.Stop() - exists, err = WalletExists( - testWalletConfig(esplora.URL, dbDir, nil, password), - ) + exists, err = WalletExists(walletConfig(nil)) require.NoError(t, err) require.True(t, exists) // Re-creating over an existing wallet database must be refused: // btcwallet would silently ignore the new seed and open the old // wallet. - _, err = New(testWalletConfig(esplora.URL, dbDir, seed[:], password)) + _, err = New(walletConfig(seed[:])) require.ErrorIs(t, err, ErrWalletExists) // Reopen with the passphrase only and confirm it is the same // wallet by deriving the same address chain. - w, err = New(testWalletConfig(esplora.URL, dbDir, nil, password)) + w, err = New(walletConfig(nil)) require.NoError(t, err) require.NoError(t, w.Start()) t.Cleanup(w.Stop) @@ -167,7 +185,7 @@ func TestWalletWrongPasswordRetry(t *testing.T) { require.ErrorContains(t, err, "invalid passphrase") // The failed Start must have unwound cleanly: retrying with the - // correct password would block on the bbolt file lock if the + // correct password would block on the database's file lock if the // database were still open. w, err = New(testWalletConfig(esplora.URL, dbDir, nil, password)) require.NoError(t, err) diff --git a/lwwallet/walletdb.go b/lwwallet/walletdb.go new file mode 100644 index 000000000..9cbc040c9 --- /dev/null +++ b/lwwallet/walletdb.go @@ -0,0 +1,60 @@ +package lwwallet + +import ( + "context" + "time" + + "github.com/btcsuite/btcwallet/walletdb" + "github.com/lightninglabs/wavelength/internal/sqlbase" +) + +const ( + // DBBackendBolt keeps btcwallet's wallet database in the classic + // BoltDB file under Config.DBDir. It is the default. + DBBackendBolt = "bolt" + + // DBBackendSQLite keeps btcwallet's wallet database in a SQLite + // database under Config.DBDir. It lets an embedded daemon hold + // all of its state in one storage engine, in a directory the host + // application picks, without an mmap'd BoltDB file that a + // platform's file-backup machinery has to be told to skip. + DBBackendSQLite = "sqlite" +) + +const ( + // sqlWalletDBTablePrefix namespaces btcwallet's key/value table + // inside the wallet database, so the resulting "walletdb_kv" + // table can coexist with tables owned by other stores. + sqlWalletDBTablePrefix = "walletdb" + + // sqlWalletDBTimeout is the per-query timeout applied to the + // wallet database. + sqlWalletDBTimeout = 30 * time.Second + + // sqlWalletDBMaxConnections bounds the connection pool sqlbase + // keeps per DSN. The walletdb emulation funnels its read-write + // transactions through a single in-process lock anyway, so a + // second connection would only add contention on the database's + // own write lock. + sqlWalletDBMaxConnections = 1 +) + +// openSQLWalletDB opens, creating it if needed, btcwallet's wallet +// database on the given database/sql driver and DSN. The driver must +// already be registered by the caller. The returned handle is owned by +// the caller and must be closed once the wallet has stopped. +func openSQLWalletDB(ctx context.Context, driverName, + dsn string) (walletdb.DB, error) { + + // The connection set is process-global and initialized exactly + // once; a later call with a different limit is a no-op. + sqlbase.Init(sqlWalletDBMaxConnections) + + return sqlbase.NewSqlBackend(ctx, &sqlbase.Config{ + DriverName: driverName, + Dsn: dsn, + Timeout: sqlWalletDBTimeout, + TableNamePrefix: sqlWalletDBTablePrefix, + WithTxLevelLock: true, + }) +} diff --git a/lwwallet/walletdb_native.go b/lwwallet/walletdb_native.go index fd9b169a8..6f20f1549 100644 --- a/lwwallet/walletdb_native.go +++ b/lwwallet/walletdb_native.go @@ -3,40 +3,203 @@ package lwwallet import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" "time" + btcwalletbase "github.com/btcsuite/btcwallet/wallet" "github.com/lightningnetwork/lnd/lnwallet/btcwallet" + _ "modernc.org/sqlite" // Register the native SQLite driver. ) -// newWalletLoaderOptions returns the native btcwallet bbolt loader -// options. The cleanup func is a no-op: the local database is opened -// (and closed on failure) by btcwallet's own loader. +const ( + // boltWalletDBTimeout is how long the BoltDB backend waits for + // the database file lock before giving up. + boltWalletDBTimeout = 60 * time.Second + + // sqliteWalletDBDriverName is the database/sql driver registered + // by modernc.org/sqlite. + sqliteWalletDBDriverName = "sqlite" + + // sqliteWalletDBFileName is the SQLite wallet database's file + // name under Config.DBDir. It deliberately differs from + // btcwallet's BoltDB file name, so that neither backend can ever + // be pointed at a database written by the other. + sqliteWalletDBFileName = "wallet.sqlite.db" +) + +// walletDBPath returns the path of the wallet database file the +// configured backend uses, and fails on an unknown backend so a typo +// cannot silently resolve to the default. +// +// Resolution also fails when the other backend's database file already +// exists in DBDir. The two backends store the wallet under different +// names, so without this check switching the backend of an initialized +// wallet directory looks exactly like a first start: btcwallet would +// happily create a second, empty wallet next to the existing — and +// possibly funded — one. +func walletDBPath(cfg Config) (string, error) { + var backend, other string + switch cfg.DBBackend { + case "", DBBackendBolt: + backend, other = DBBackendBolt, DBBackendSQLite + + case DBBackendSQLite: + backend, other = DBBackendSQLite, DBBackendBolt + + default: + return "", fmt.Errorf("unknown wallet database backend %q, "+ + "must be %q or %q", cfg.DBBackend, DBBackendBolt, + DBBackendSQLite) + } + + otherPath := walletDBFilePath(cfg.DBDir, other) + otherExists, err := walletDBExists(otherPath) + if err != nil { + return "", err + } + if otherExists { + return "", fmt.Errorf("wallet database %s exists but the %q "+ + "database backend is selected; set the backend to %q", + otherPath, backend, other) + } + + return walletDBFilePath(cfg.DBDir, backend), nil +} + +// walletDBFilePath returns the file the given backend keeps the wallet +// database in. +func walletDBFilePath(dbDir, backend string) string { + if backend == DBBackendSQLite { + return filepath.Join(dbDir, sqliteWalletDBFileName) + } + + return filepath.Join(dbDir, btcwalletbase.WalletDBName) +} + +// newWalletLoaderOptions returns the btcwallet loader options for the +// configured wallet database backend, plus a func that releases +// whatever the resolution opened. func newWalletLoaderOptions(cfg Config) ([]btcwallet.LoaderOption, func(), error) { + // Resolving the path is also what validates the backend and + // rejects a directory that already holds the other backend's + // database. + dbPath, err := walletDBPath(cfg) + if err != nil { + return nil, nil, err + } + + // BoltDB is opened (and closed on failure) by btcwallet's own + // loader, so there is nothing for the cleanup func to release. + if cfg.DBBackend != DBBackendSQLite { + return []btcwallet.LoaderOption{ + btcwallet.LoaderWithLocalWalletDB( + cfg.DBDir, false, boltWalletDBTimeout, + ), + }, func() {}, nil + } + + if err := os.MkdirAll(cfg.DBDir, 0700); err != nil { + return nil, nil, fmt.Errorf("create wallet database dir: %w", + err) + } + + db, err := openSQLWalletDB( + context.Background(), sqliteWalletDBDriverName, + sqliteWalletDBDSN(dbPath), + ) + if err != nil { + return nil, nil, fmt.Errorf("open SQLite wallet database: %w", + err) + } + + // btcwallet's loader only closes a wallet database it created + // itself, so a constructor failure after this point would leak + // this handle, and its lock on the database file, for the process + // lifetime. + cleanup := func() { + _ = db.Close() + } + return []btcwallet.LoaderOption{ - btcwallet.LoaderWithLocalWalletDB( - cfg.DBDir, false, 60*time.Second, - ), - }, func() {}, nil + btcwallet.LoaderWithExternalWalletDB(db), + }, cleanup, nil } -// walletExists reports whether a btcwallet bbolt database already -// exists in the configured directory. For local databases the loader -// only checks file existence, so this probe does not take the bbolt -// file lock. +// walletExists reports whether a wallet database has already been +// created for the configured backend. +// +// As with btcwallet's own probe for a local database, the answer is the +// presence of the database file rather than of an initialized wallet +// inside it. That keeps the probe free of side effects, which matters +// for a SQL backend in a way it does not for BoltDB: opening the +// database to look inside would create the very database the caller is +// asking about, and would hold its write lock until the handle is +// closed again. func walletExists(cfg Config) (bool, error) { - opts, _, err := newWalletLoaderOptions(cfg) + dbPath, err := walletDBPath(cfg) if err != nil { return false, err } - loader, err := btcwallet.NewWalletLoader( - cfg.ChainParams, cfg.RecoveryWindow, opts..., - ) - if err != nil { - return false, err + return walletDBExists(dbPath) +} + +// sqliteWalletDBDSN returns the modernc.org/sqlite DSN for the wallet +// database at the given path. +func sqliteWalletDBDSN(dbPath string) string { + pragmas := make(url.Values) + for _, pragma := range []string{ + // The key/value schema nests buckets through a parent_id + // self-reference, and relies on the cascade to drop a + // deleted bucket's children instead of leaving them + // behind as unreachable rows. + "foreign_keys=on", + + // WAL lets the wallet's reads proceed against the last + // committed snapshot while a write transaction is open, + // and keeps a crash from tearing a commit. + "journal_mode=WAL", + + // busy_timeout caps how long SQLite waits for a lock held + // by another connection to the same file, such as a + // backup tool, before failing the query. + "busy_timeout=30000", + } { + pragmas.Add("_pragma", pragma) } - return loader.WalletExists() + // Take the write lock when a write transaction begins rather than + // on its first write statement. The wallet reads before it writes + // within a single transaction — deriving the next address reads + // the last-used index and then bumps it — and a deferred + // transaction that tries to upgrade to a writer after another + // connection committed in the meantime fails with + // SQLITE_BUSY_SNAPSHOT. That error bypasses the busy handler, so + // busy_timeout above would not absorb it. + return fmt.Sprintf("%s?%s&_txlock=immediate", dbPath, pragmas.Encode()) +} + +// walletDBExists reports whether a wallet database exists at the given +// path. An unexpected stat failure is returned as an error rather than +// as a missing database: reading, say, a permission problem as "no +// wallet here" would send the caller down the create path. +func walletDBExists(path string) (bool, error) { + _, err := os.Stat(path) + switch { + case err == nil: + return true, nil + + case errors.Is(err, os.ErrNotExist): + return false, nil + + default: + return false, err + } } diff --git a/lwwallet/walletdb_native_test.go b/lwwallet/walletdb_native_test.go new file mode 100644 index 000000000..484a104a6 --- /dev/null +++ b/lwwallet/walletdb_native_test.go @@ -0,0 +1,150 @@ +//go:build !js || !wasm + +package lwwallet + +import ( + "os" + "path/filepath" + "testing" + + btcwalletbase "github.com/btcsuite/btcwallet/wallet" + "github.com/stretchr/testify/require" +) + +// testWalletDBBackends lists the wallet database backends the platform +// supports, so backend-agnostic tests can assert the same contract for +// each of them. +var testWalletDBBackends = []string{DBBackendBolt, DBBackendSQLite} + +// TestWalletDBPath asserts how the configured backend maps to a wallet +// database file, and in particular that a database left behind by the +// other backend is refused instead of ignored. +func TestWalletDBPath(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + + // backend is the configured Config.DBBackend value. + backend string + + // existingFile, when set, is created in the wallet + // database directory before resolution runs. + existingFile string + + // wantFile is the expected database file name. + wantFile string + + // wantErr is a substring the error must contain. + wantErr string + }{{ + name: "empty backend defaults to bolt", + backend: "", + wantFile: btcwalletbase.WalletDBName, + }, { + name: "bolt", + backend: DBBackendBolt, + wantFile: btcwalletbase.WalletDBName, + }, { + name: "sqlite", + backend: DBBackendSQLite, + wantFile: sqliteWalletDBFileName, + }, { + name: "bolt with existing bolt database", + backend: DBBackendBolt, + existingFile: btcwalletbase.WalletDBName, + wantFile: btcwalletbase.WalletDBName, + }, { + name: "sqlite with existing sqlite database", + backend: DBBackendSQLite, + existingFile: sqliteWalletDBFileName, + wantFile: sqliteWalletDBFileName, + }, { + name: "bolt with existing sqlite database", + backend: DBBackendBolt, + existingFile: sqliteWalletDBFileName, + wantErr: sqliteWalletDBFileName, + }, { + name: "sqlite with existing bolt database", + backend: DBBackendSQLite, + existingFile: btcwalletbase.WalletDBName, + wantErr: btcwalletbase.WalletDBName, + }, { + name: "unknown backend", + backend: "postgres", + wantErr: "unknown wallet database backend", + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + dbDir := t.TempDir() + if tc.existingFile != "" { + path := filepath.Join(dbDir, tc.existingFile) + require.NoError( + t, os.WriteFile( + path, nil, 0600, + ), + ) + } + + path, err := walletDBPath(Config{ + DBDir: dbDir, + DBBackend: tc.backend, + }) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + + return + } + + require.NoError(t, err) + require.Equal( + t, filepath.Join(dbDir, tc.wantFile), path, + ) + }) + } +} + +// TestSQLiteWalletDBLayout asserts that the SQLite backend keeps the +// wallet entirely inside its own database file, and in particular that +// it leaves no BoltDB database behind: a caller switching back to the +// default backend must not find one and open an empty wallet from it. +func TestSQLiteWalletDBLayout(t *testing.T) { + t.Parallel() + + esplora := newTestEsplora(t) + dbDir := t.TempDir() + + var seed [32]byte + for i := range seed { + seed[i] = byte(i + 3) + } + + cfg := testWalletConfig( + esplora.URL, dbDir, seed[:], []byte("sqlite-password"), + ) + cfg.DBBackend = DBBackendSQLite + + // A probe on a fresh directory must not create the database it is + // asking about, or the guard against mixing up the two backends + // would trip on a database that never held a wallet. + exists, err := WalletExists(cfg) + require.NoError(t, err) + require.False(t, exists) + + entries, err := os.ReadDir(dbDir) + require.NoError(t, err) + require.Empty(t, entries) + + w, err := New(cfg) + require.NoError(t, err) + require.NoError(t, w.Start()) + w.Stop() + + require.FileExists(t, filepath.Join(dbDir, sqliteWalletDBFileName)) + require.NoFileExists( + t, filepath.Join(dbDir, btcwalletbase.WalletDBName), + ) +} diff --git a/lwwallet/walletdb_wasm.go b/lwwallet/walletdb_wasm.go index 3a8bd5ca2..3da2a7241 100644 --- a/lwwallet/walletdb_wasm.go +++ b/lwwallet/walletdb_wasm.go @@ -13,17 +13,13 @@ import ( "github.com/btcsuite/btcwallet/walletdb" _ "github.com/lightninglabs/go-wasmsqlite" - "github.com/lightninglabs/wavelength/internal/sqlbase" "github.com/lightninglabs/wavelength/internal/wasmhost" "github.com/lightningnetwork/lnd/lnwallet/btcwallet" ) const ( wasmWalletDBDriverName = "wasmsqlite" - wasmWalletDBTablePrefix = "walletdb" - wasmWalletDBTimeout = 30 * time.Second wasmWalletDBBusyTimeoutMS = "30000" - wasmWalletDBMaxConnections = 1 wasmWalletDBFileNamePattern = "/wallet-%016x.db" // nodeWalletDBFileName is the wallet store's name on a real filesystem, @@ -83,19 +79,13 @@ func walletExists(cfg Config) (bool, error) { // openWASMWalletDB opens btcwallet's walletdb on top of the same browser // SQLite/OPFS driver used by the daemon and swap stores. func openWASMWalletDB(dbDir string) (walletdb.DB, error) { - sqlbase.Init(wasmWalletDBMaxConnections) - - cfg := &sqlbase.Config{ - DriverName: wasmWalletDBDriverName, - Dsn: wasmWalletDBDSN(dbDir), - Timeout: wasmWalletDBTimeout, - TableNamePrefix: wasmWalletDBTablePrefix, - WithTxLevelLock: true, - } + dsn := wasmWalletDBDSN(dbDir) var lastErr error for attempt := 0; attempt < 25; attempt++ { - db, err := sqlbase.NewSqlBackend(context.Background(), cfg) + db, err := openSQLWalletDB( + context.Background(), wasmWalletDBDriverName, dsn, + ) if err == nil { return db, nil } diff --git a/lwwallet/walletdb_wasm_test.go b/lwwallet/walletdb_wasm_test.go new file mode 100644 index 000000000..570286a3b --- /dev/null +++ b/lwwallet/walletdb_wasm_test.go @@ -0,0 +1,9 @@ +//go:build js && wasm + +package lwwallet + +// testWalletDBBackends lists the wallet database backends the platform +// supports. Browser builds have exactly one: BoltDB cannot be used +// under js/wasm, so Config.DBBackend is ignored there and the store is +// always the OPFS-backed SQLite database. +var testWalletDBBackends = []string{""} diff --git a/sample-waved.conf b/sample-waved.conf index 2e18f6b67..0a0fe46d3 100644 --- a/sample-waved.conf +++ b/sample-waved.conf @@ -192,6 +192,13 @@ # Address recovery look-ahead window for lwwallet. # wallet.recoverywindow=100 +# Database backend for the lwwallet wallet database: bolt or sqlite. Both keep +# the database in the network data directory. Pick sqlite to keep the directory +# free of a BoltDB file, for instance so a backup can copy it with a single +# storage engine's guarantees. The backend cannot be changed once the wallet +# has been created. +# wallet.dbbackend=bolt + # Path to a file containing the wallet password for auto-unlock at startup. # wallet.password_file= diff --git a/waved/config.go b/waved/config.go index 5558a1212..2484c43da 100644 --- a/waved/config.go +++ b/waved/config.go @@ -1123,6 +1123,13 @@ type WalletConfig struct { // automatically without requiring an UnlockWallet RPC call. PasswordFile string `mapstructure:"password_file"` + // DBBackend selects the database engine the lwwallet backend + // keeps btcwallet's wallet database in: "bolt" (the default) uses + // the classic BoltDB file, "sqlite" uses a SQLite database so the + // network directory holds no BoltDB file at all. Both live in the + // network directory. Only used when Type is "lwwallet". + DBBackend string `mapstructure:"dbbackend"` + // BtcwalletPeers is a list of host:port addresses for neutrino // to connect to exclusively (no DNS seeding). Only used when // Type is "btcwallet". @@ -1198,6 +1205,7 @@ func DefaultConfig() *Config { Type: DefaultWalletType, PollInterval: DefaultEsploraPollInterval, RecoveryWindow: DefaultRecoveryWindow, + DBBackend: lwwallet.DBBackendBolt, }, Swap: &SwapConfig{ ServerTransport: RPCTransportGRPC, @@ -1401,6 +1409,20 @@ func (c *Config) validateWalletConfig() error { c.Wallet.EsploraURL = esploraURL } + // Reject an unknown database backend here rather than at + // wallet-unlock time: a typo would otherwise only surface + // once the operator tries to use the wallet. + switch c.Wallet.DBBackend { + case "", lwwallet.DBBackendBolt, lwwallet.DBBackendSQLite: + // An empty value keeps the lwwallet default. + + default: + return fmt.Errorf("unknown wallet.dbbackend %q, valid "+ + "values: %s, %s", c.Wallet.DBBackend, + lwwallet.DBBackendBolt, + lwwallet.DBBackendSQLite) + } + case WalletTypeBtcwallet: // Neutrino has no mempool visibility, so fee estimation // always requires an external API. An empty value falls diff --git a/waved/config_wallet_dbbackend_test.go b/waved/config_wallet_dbbackend_test.go new file mode 100644 index 000000000..825a2b698 --- /dev/null +++ b/waved/config_wallet_dbbackend_test.go @@ -0,0 +1,59 @@ +package waved + +import ( + "testing" + + "github.com/lightninglabs/wavelength/lwwallet" + "github.com/stretchr/testify/require" +) + +// TestConfigValidateWalletDBBackend checks that the lwwallet database +// backend selector only accepts the backends lwwallet implements, so a +// typo fails at startup instead of at wallet-unlock time. +func TestConfigValidateWalletDBBackend(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + backend string + wantErr string + }{ + { + name: "unset keeps the default", + }, + { + name: "bolt", + backend: lwwallet.DBBackendBolt, + }, + { + name: "sqlite", + backend: lwwallet.DBBackendSQLite, + }, + { + name: "unknown backend", + backend: "postgres", + wantErr: "unknown wallet.dbbackend", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + cfg.Network = "regtest" + cfg.Wallet.Type = WalletTypeLwwallet + cfg.Wallet.EsploraURL = "http://127.0.0.1:3000" + cfg.Wallet.DBBackend = tc.backend + + err := cfg.Validate() + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/waved/rpc_wallet.go b/waved/rpc_wallet.go index 12abb722a..02db1af6e 100644 --- a/waved/rpc_wallet.go +++ b/waved/rpc_wallet.go @@ -696,14 +696,15 @@ func isWrongPassphraseErr(err error) bool { // selfManagedWalletExists probes whether a wallet database has been // created for the configured self-managed wallet backend. Only the -// chain params and data directory are consulted; the probe does not -// open the database on native builds. +// chain params, data directory and database backend are consulted; the +// probe does not open a database that does not exist yet. func (s *Server) selfManagedWalletExists() (bool, error) { switch s.cfg.Wallet.Type { case WalletTypeLwwallet: return lwwallet.WalletExists(lwwallet.Config{ ChainParams: s.chainParams, DBDir: s.cfg.NetworkDir(), + DBBackend: s.cfg.Wallet.DBBackend, }) case WalletTypeBtcwallet: diff --git a/waved/server.go b/waved/server.go index 35791016f..fefe75cc8 100644 --- a/waved/server.go +++ b/waved/server.go @@ -1993,6 +1993,7 @@ func (s *Server) startLwwallet(ctx context.Context, seed []byte, PollInterval: pollInterval, RecoveryWindow: recoveryWindow, DBDir: networkDir, + DBBackend: s.cfg.Wallet.DBBackend, Log: fn.Some(s.subLogger(lwwallet.Subsystem)), }) if err != nil {