diff --git a/cmd/commands/cmd_walletunlocker.go b/cmd/commands/cmd_walletunlocker.go index c396a038cf..0c04ce25c0 100644 --- a/cmd/commands/cmd_walletunlocker.go +++ b/cmd/commands/cmd_walletunlocker.go @@ -3,8 +3,11 @@ package commands import ( "bufio" "bytes" + "context" "encoding/hex" + "errors" "fmt" + "io" "os" "strconv" "strings" @@ -15,6 +18,8 @@ import ( "github.com/lightningnetwork/lnd/macaroons" "github.com/lightningnetwork/lnd/walletunlocker" "github.com/urfave/cli" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) var ( @@ -502,11 +507,32 @@ var unlockCommand = cli.Command{ Action: actionDecorator(unlock), } +// unlock is the lncli entry point for unlocking the wallet using the +// WalletUnlocker service. func unlock(ctx *cli.Context) error { - ctxc := getContext() - client, cleanUp := getWalletUnlockerClient(ctx) + return unlockWithDeps( + ctx, readPassword, getWalletUnlockerClient, + getStateServiceClient, getContext, os.Stdin, + ) +} + +// unlockWithDeps performs the unlock flow with injected dependencies to +// simplify unit testing. +func unlockWithDeps(ctx *cli.Context, + readPasswordFn func(string) ([]byte, error), + getUnlockerClientFn func(*cli.Context) (lnrpc.WalletUnlockerClient, + func()), + getStateClientFn func(*cli.Context) (lnrpc.StateClient, func()), + getContextFn func() context.Context, stdin io.Reader) error { + + ctxc := getContextFn() + client, cleanUp := getUnlockerClientFn(ctx) defer cleanUp() + // Use the always-on state service to wait for unlock readiness. + stateClient, stateCleanUp := getStateClientFn(ctx) + defer stateCleanUp() + var ( pw []byte err error @@ -517,7 +543,7 @@ func unlock(ctx *cli.Context) error { // password manager. If the user types the password instead, it will be // echoed in the console. case ctx.IsSet("stdin"): - reader := bufio.NewReader(os.Stdin) + reader := bufio.NewReader(stdin) pw, err = reader.ReadBytes('\n') // Remove carriage return and newline characters. @@ -527,7 +553,7 @@ func unlock(ctx *cli.Context) error { // terminal to be a real tty and will fail if a string is piped into // lncli. default: - pw, err = readPassword("Input wallet password: ") + pw, err = readPasswordFn("Input wallet password: ") } if err != nil { return err @@ -555,11 +581,30 @@ func unlock(ctx *cli.Context) error { RecoveryWindow: recoveryWindow, StatelessInit: ctx.Bool(statelessInitFlag.Name), } + + // Wait until lnd reports the wallet is locked and ready to accept + // an unlock request. + waitCtx, cancel := context.WithCancel(ctxc) + err = waitForWalletLocked(waitCtx, stateClient) + cancel() + if err != nil { + return err + } + + // Submit the unlock request once the wallet is ready. _, err = client.UnlockWallet(ctxc, req) if err != nil { return err } + // Wait until the wallet is fully unlocked (or RPC/server active). + waitCtx, cancel = context.WithCancel(ctxc) + err = waitForWalletUnlocked(waitCtx, stateClient) + cancel() + if err != nil { + return err + } + fmt.Println("\nlnd successfully unlocked!") // TODO(roasbeef): add ability to accept hex single and multi backups @@ -567,6 +612,138 @@ func unlock(ctx *cli.Context) error { return nil } +// waitForWalletState consumes the StateService stream until the check function +// reports completion or the stream ends. +func waitForWalletState(ctx context.Context, client lnrpc.StateClient, + check func(lnrpc.WalletState) (bool, error)) error { + + stream, err := client.SubscribeState( + ctx, &lnrpc.SubscribeStateRequest{}, + ) + if err != nil { + return err + } + + for { + resp, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + return errors.New("lnd shut down before " + + "reaching expected wallet state") + } + + return err + } + + state := resp.GetState() + fmt.Printf("wallet state: %s\n", state) + + done, err := check(state) + if done { + return err + } + } +} + +// waitForWalletLocked blocks until the wallet reaches LOCKED, or errors if the +// wallet is missing or already unlocked. +func waitForWalletLocked(ctx context.Context, client lnrpc.StateClient) error { + check := func(state lnrpc.WalletState) (bool, error) { + switch state { + case lnrpc.WalletState_LOCKED: + return true, nil + + case lnrpc.WalletState_NON_EXISTING: + return true, errors.New("wallet is not initialized - " + + "please run 'lncli create'") + + case lnrpc.WalletState_UNLOCKED, + lnrpc.WalletState_RPC_ACTIVE, + lnrpc.WalletState_SERVER_ACTIVE: + + return true, errors.New("wallet is already unlocked") + + default: + return false, nil + } + } + + err := waitForWalletState(ctx, client, check) + if err == nil { + return nil + } + + if s, ok := status.FromError(err); ok { + switch s.Code() { + case codes.Unimplemented: + fmt.Println("StateService not available, " + + "skipping wait for locked state") + + return nil + + case codes.Unavailable: + // The state service may be temporarily unreachable. + fmt.Println("StateService unavailable, " + + "skipping wait for locked state") + + return nil + + default: + } + } + + return err +} + +// waitForWalletUnlocked blocks until the wallet reaches UNLOCKED or beyond, +// or errors if the wallet is missing. +func waitForWalletUnlocked(ctx context.Context, + client lnrpc.StateClient) error { + + check := func(state lnrpc.WalletState) (bool, error) { + switch state { + case lnrpc.WalletState_UNLOCKED, + lnrpc.WalletState_RPC_ACTIVE, + lnrpc.WalletState_SERVER_ACTIVE: + + return true, nil + + case lnrpc.WalletState_NON_EXISTING: + return true, errors.New("wallet is not initialized - " + + "please run 'lncli create'") + + default: + return false, nil + } + } + + err := waitForWalletState(ctx, client, check) + if err == nil { + return nil + } + + if s, ok := status.FromError(err); ok { + switch s.Code() { + case codes.Unimplemented: + fmt.Println("StateService not available, " + + "skipping wait for unlocked state") + + return nil + + case codes.Unavailable: + // The state service may be temporarily unreachable. + fmt.Println("StateService unavailable, " + + "skipping wait for unlocked state") + + return nil + + default: + } + } + + return err +} + var changePasswordCommand = cli.Command{ Name: "changepassword", Category: "Startup", diff --git a/cmd/commands/cmd_walletunlocker_test.go b/cmd/commands/cmd_walletunlocker_test.go new file mode 100644 index 0000000000..91e4be9136 --- /dev/null +++ b/cmd/commands/cmd_walletunlocker_test.go @@ -0,0 +1,661 @@ +package commands + +import ( + "context" + "errors" + "flag" + "io" + "strings" + "testing" + + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/stretchr/testify/require" + "github.com/urfave/cli" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// fakeClientStream implements grpc.ClientStream for tests. +type fakeClientStream struct{} + +// Header returns empty metadata for the fake client stream. +func (fakeClientStream) Header() (metadata.MD, error) { + return nil, nil +} + +// Trailer returns empty metadata for the fake client stream. +func (fakeClientStream) Trailer() metadata.MD { + return nil +} + +// CloseSend is a no-op for the fake client stream. +func (fakeClientStream) CloseSend() error { + return nil +} + +// Context returns a background context for the fake client stream. +func (fakeClientStream) Context() context.Context { + return context.Background() +} + +// SendMsg is a no-op for the fake client stream. +func (fakeClientStream) SendMsg(interface{}) error { + return nil +} + +// RecvMsg is a no-op for the fake client stream. +func (fakeClientStream) RecvMsg(interface{}) error { + return nil +} + +// stateStreamSpec describes the scripted responses for a state stream. +type stateStreamSpec struct { + states []lnrpc.WalletState + err error +} + +// fakeStateStream implements State_SubscribeStateClient with scripted states. +type fakeStateStream struct { + fakeClientStream + states []lnrpc.WalletState + err error + idx int +} + +// Recv returns the next scripted wallet state or the configured error. +func (f *fakeStateStream) Recv() (*lnrpc.SubscribeStateResponse, error) { + if f.idx < len(f.states) { + state := f.states[f.idx] + f.idx++ + + return &lnrpc.SubscribeStateResponse{ + State: state, + }, nil + } + + if f.err != nil { + return nil, f.err + } + + return nil, io.EOF +} + +// fakeStateClient implements lnrpc.StateClient with scripted streams. +type fakeStateClient struct { + streams []stateStreamSpec + subscribeCalls int + subscribeInputs []*lnrpc.SubscribeStateRequest +} + +// SubscribeState returns a scripted stream for the fake state client. +func (f *fakeStateClient) SubscribeState(_ context.Context, + in *lnrpc.SubscribeStateRequest, + _ ...grpc.CallOption) (lnrpc.State_SubscribeStateClient, error) { + + f.subscribeCalls++ + f.subscribeInputs = append(f.subscribeInputs, in) + + if f.subscribeCalls > len(f.streams) { + return nil, errors.New("unexpected SubscribeState call") + } + + streamSpec := f.streams[f.subscribeCalls-1] + + return &fakeStateStream{ + states: streamSpec.states, + err: streamSpec.err, + }, nil +} + +// GetState is unused in tests and returns a sentinel error. +func (f *fakeStateClient) GetState(_ context.Context, + _ *lnrpc.GetStateRequest, + _ ...grpc.CallOption) (*lnrpc.GetStateResponse, error) { + + return nil, errors.New("not implemented") +} + +// Ensure fakeStateClient satisfies the lnrpc.StateClient interface. +var _ lnrpc.StateClient = (*fakeStateClient)(nil) + +// errNotImplemented is returned by fake methods that are unused in tests. +var errNotImplemented = errors.New("not implemented") + +// fakeUnlockerClient implements lnrpc.WalletUnlockerClient for tests. +type fakeUnlockerClient struct { + unlockCalls int + lastReq *lnrpc.UnlockWalletRequest + unlockErr error +} + +// GenSeed is unused in tests and returns a sentinel error. +func (f *fakeUnlockerClient) GenSeed(_ context.Context, _ *lnrpc.GenSeedRequest, + _ ...grpc.CallOption) (*lnrpc.GenSeedResponse, error) { + + return nil, errNotImplemented +} + +// InitWallet is unused in tests and returns a sentinel error. +func (f *fakeUnlockerClient) InitWallet(_ context.Context, + _ *lnrpc.InitWalletRequest, + _ ...grpc.CallOption) (*lnrpc.InitWalletResponse, error) { + + return nil, errNotImplemented +} + +// UnlockWallet records the request and returns the configured response. +func (f *fakeUnlockerClient) UnlockWallet(_ context.Context, + in *lnrpc.UnlockWalletRequest, + _ ...grpc.CallOption) (*lnrpc.UnlockWalletResponse, error) { + + f.unlockCalls++ + f.lastReq = in + + if f.unlockErr != nil { + return nil, f.unlockErr + } + + return &lnrpc.UnlockWalletResponse{}, nil +} + +// ChangePassword is unused in tests and returns a sentinel error. +func (f *fakeUnlockerClient) ChangePassword(_ context.Context, + _ *lnrpc.ChangePasswordRequest, + _ ...grpc.CallOption) (*lnrpc.ChangePasswordResponse, error) { + + return nil, errNotImplemented +} + +// Ensure fakeUnlockerClient satisfies the lnrpc.WalletUnlockerClient interface. +var _ lnrpc.WalletUnlockerClient = (*fakeUnlockerClient)(nil) + +// newUnlockContext builds a cli.Context with unlock flags parsed. +func newUnlockContext(t *testing.T, args []string) *cli.Context { + t.Helper() + + flagSet := flag.NewFlagSet("unlock", flag.ContinueOnError) + flagSet.SetOutput(io.Discard) + flagSet.Bool("stdin", false, "") + flagSet.Int64("recovery_window", 0, "") + flagSet.Bool("stateless_init", false, "") + + err := flagSet.Parse(args) + require.NoError(t, err) + + app := cli.NewApp() + + return cli.NewContext(app, flagSet, nil) +} + +// TestUnlock exercises wallet unlock command across success and error paths. +func TestUnlock(t *testing.T) { + // Shortcut for a long name. + const waitingToString = lnrpc.WalletState_WAITING_TO_START + + // Define table-driven cases for unlockWithDeps behavior and inputs. + testCases := []struct { + name string + args []string + stdinInput string + readPasswordRet []byte + readPasswordErr error + stateStreams []stateStreamSpec + unlockerErr error + expectErr string + expectReadPasswordCalls int + expectUnlockCalls int + expectSubscribeCalls int + expectReq *lnrpc.UnlockWalletRequest + }{ + // Succeeds by waiting for locked then RPC active. + { + name: "success_default", + readPasswordRet: []byte("pw"), + stateStreams: []stateStreamSpec{ + { + states: []lnrpc.WalletState{ + waitingToString, + lnrpc.WalletState_LOCKED, + }, + }, + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_RPC_ACTIVE, + }, + }, + }, + expectReadPasswordCalls: 1, + expectUnlockCalls: 1, + expectSubscribeCalls: 2, + expectReq: &lnrpc.UnlockWalletRequest{ + WalletPassword: []byte("pw"), + RecoveryWindow: 0, + StatelessInit: false, + }, + }, + + // Uses stdin, stateless init, and recovery window flag. + { + name: "success_stdin_flag_recovery_stateless", + args: []string{ + "--stdin", "--stateless_init", + "--recovery_window=50", + }, + stdinInput: "secret\n", + stateStreams: []stateStreamSpec{ + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_LOCKED, + }, + }, + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_UNLOCKED, + }, + }, + }, + expectReadPasswordCalls: 0, + expectUnlockCalls: 1, + expectSubscribeCalls: 2, + expectReq: &lnrpc.UnlockWalletRequest{ + WalletPassword: []byte("secret"), + RecoveryWindow: 50, + StatelessInit: true, + }, + }, + + // Uses positional recovery window argument. + { + name: "success_arg_recovery_window", + args: []string{"25"}, + readPasswordRet: []byte("pw"), + stateStreams: []stateStreamSpec{ + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_LOCKED, + }, + }, + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_SERVER_ACTIVE, + }, + }, + }, + expectReadPasswordCalls: 1, + expectUnlockCalls: 1, + expectSubscribeCalls: 2, + expectReq: &lnrpc.UnlockWalletRequest{ + WalletPassword: []byte("pw"), + RecoveryWindow: 25, + StatelessInit: false, + }, + }, + + // Propagates password read errors. + { + name: "read_password_error", + readPasswordErr: errors.New("read fail"), + expectErr: "read fail", + expectReadPasswordCalls: 1, + }, + + // Fails when positional recovery window is not an int. + { + name: "bad_recovery_arg", + args: []string{"not-int"}, + readPasswordRet: []byte("pw"), + expectErr: "invalid syntax", + expectReadPasswordCalls: 1, + }, + + // EOF while waiting for locked state returns a descriptive + // error. + { + name: "wait_locked_eof", + readPasswordRet: []byte("pw"), + stateStreams: []stateStreamSpec{ + { + err: io.EOF, + }, + }, + expectErr: "lnd shut down before reaching expected " + + "wallet state", + expectReadPasswordCalls: 1, + expectSubscribeCalls: 1, + }, + + // Unimplemented StateService skips lock wait then succeeds. + { + name: "wait_locked_unimplemented", + readPasswordRet: []byte("pw"), + stateStreams: []stateStreamSpec{ + { + err: status.Error( + codes.Unimplemented, "no state", + ), + }, + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_UNLOCKED, + }, + }, + }, + expectReadPasswordCalls: 1, + expectUnlockCalls: 1, + expectSubscribeCalls: 2, + expectReq: &lnrpc.UnlockWalletRequest{ + WalletPassword: []byte("pw"), + RecoveryWindow: 0, + StatelessInit: false, + }, + }, + + // Unavailable StateService skips lock wait then succeeds. + { + name: "wait_locked_unavailable", + readPasswordRet: []byte("pw"), + stateStreams: []stateStreamSpec{ + { + err: status.Error( + codes.Unavailable, "no state", + ), + }, + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_RPC_ACTIVE, + }, + }, + }, + expectReadPasswordCalls: 1, + expectUnlockCalls: 1, + expectSubscribeCalls: 2, + expectReq: &lnrpc.UnlockWalletRequest{ + WalletPassword: []byte("pw"), + RecoveryWindow: 0, + StatelessInit: false, + }, + }, + + // NON_EXISTING during lock wait fails before unlock. + { + name: "wait_locked_non_existing", + readPasswordRet: []byte("pw"), + stateStreams: []stateStreamSpec{ + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_NON_EXISTING, + }, + }, + }, + expectErr: "wallet is not initialized - please run " + + "'lncli create'", + expectReadPasswordCalls: 1, + expectSubscribeCalls: 1, + }, + + // Already unlocked during lock wait fails before unlock. + { + name: "wait_locked_already_unlocked", + readPasswordRet: []byte("pw"), + stateStreams: []stateStreamSpec{ + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_UNLOCKED, + }, + }, + }, + expectErr: "wallet is already unlocked", + expectReadPasswordCalls: 1, + expectSubscribeCalls: 1, + }, + + // Unlock RPC error is returned after lock wait succeeds. + { + name: "unlocker_error", + readPasswordRet: []byte("pw"), + stateStreams: []stateStreamSpec{ + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_LOCKED, + }, + }, + }, + unlockerErr: errors.New("unlock failed"), + expectErr: "unlock failed", + expectReadPasswordCalls: 1, + expectUnlockCalls: 1, + expectSubscribeCalls: 1, + expectReq: &lnrpc.UnlockWalletRequest{ + WalletPassword: []byte("pw"), + RecoveryWindow: 0, + StatelessInit: false, + }, + }, + + // EOF while waiting for unlocked state returns a descriptive + // error. + { + name: "wait_unlocked_eof", + readPasswordRet: []byte("pw"), + stateStreams: []stateStreamSpec{ + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_LOCKED, + }, + }, + { + err: io.EOF, + }, + }, + expectErr: "lnd shut down before reaching expected " + + "wallet state", + expectReadPasswordCalls: 1, + expectUnlockCalls: 1, + expectSubscribeCalls: 2, + expectReq: &lnrpc.UnlockWalletRequest{ + WalletPassword: []byte("pw"), + RecoveryWindow: 0, + StatelessInit: false, + }, + }, + + // NON_EXISTING during unlocked wait fails after unlock attempt. + { + name: "wait_unlocked_non_existing", + readPasswordRet: []byte("pw"), + stateStreams: []stateStreamSpec{ + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_LOCKED, + }, + }, + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_NON_EXISTING, + }, + }, + }, + expectErr: "wallet is not initialized - please run " + + "'lncli create'", + expectReadPasswordCalls: 1, + expectUnlockCalls: 1, + expectSubscribeCalls: 2, + expectReq: &lnrpc.UnlockWalletRequest{ + WalletPassword: []byte("pw"), + RecoveryWindow: 0, + StatelessInit: false, + }, + }, + + // Unimplemented StateService skips unlock wait. + { + name: "wait_unlocked_unimplemented", + readPasswordRet: []byte("pw"), + stateStreams: []stateStreamSpec{ + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_LOCKED, + }, + }, + { + err: status.Error( + codes.Unimplemented, "no state", + ), + }, + }, + expectReadPasswordCalls: 1, + expectUnlockCalls: 1, + expectSubscribeCalls: 2, + expectReq: &lnrpc.UnlockWalletRequest{ + WalletPassword: []byte("pw"), + RecoveryWindow: 0, + StatelessInit: false, + }, + }, + + // Unavailable StateService skips unlock wait. + { + name: "wait_unlocked_unavailable", + readPasswordRet: []byte("pw"), + stateStreams: []stateStreamSpec{ + { + states: []lnrpc.WalletState{ + lnrpc.WalletState_LOCKED, + }, + }, + { + err: status.Error( + codes.Unavailable, "no state", + ), + }, + }, + expectReadPasswordCalls: 1, + expectUnlockCalls: 1, + expectSubscribeCalls: 2, + expectReq: &lnrpc.UnlockWalletRequest{ + WalletPassword: []byte("pw"), + RecoveryWindow: 0, + StatelessInit: false, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Build the CLI context with unlock flags and args. + ctx := newUnlockContext(t, tc.args) + + // Create fake clients with scripted responses. + unlocker := &fakeUnlockerClient{ + unlockErr: tc.unlockerErr, + } + stateClient := &fakeStateClient{ + streams: tc.stateStreams, + } + + // Track cleanup for the unlocker client. + unlockerCleaned := false + getUnlockerClient := func( + *cli.Context) (lnrpc.WalletUnlockerClient, + func()) { + + return unlocker, func() { + unlockerCleaned = true + } + } + + // Track cleanup for the state client. + stateCleaned := false + getStateClient := func(*cli.Context) (lnrpc.StateClient, + func()) { + + return stateClient, func() { + stateCleaned = true + } + } + + // Capture password prompt and inject return values. + readPrompt := "" + readCalls := 0 + readPasswordFn := func(prompt string) ([]byte, error) { + readPrompt = prompt + readCalls++ + + return tc.readPasswordRet, tc.readPasswordErr + } + + // Provide a deterministic context without signal + // handling. + contextCalls := 0 + getContextFn := func() context.Context { + contextCalls++ + + return t.Context() + } + + // Provide stdin input via injected reader. + stdin := strings.NewReader(tc.stdinInput) + + // Execute unlockWithDeps with injected dependencies. + err := unlockWithDeps( + ctx, readPasswordFn, getUnlockerClient, + getStateClient, getContextFn, stdin, + ) + + // Assert error behavior. + if tc.expectErr != "" { + require.ErrorContains(t, err, tc.expectErr) + } else { + require.NoError(t, err) + } + + // Verify password prompt usage. + require.Equal(t, tc.expectReadPasswordCalls, readCalls) + if readCalls > 0 { + require.Equal( + t, "Input wallet password: ", + readPrompt, + ) + } + + // Verify client usage and cleanup behavior. + require.Equal( + t, tc.expectUnlockCalls, unlocker.unlockCalls, + ) + require.Equal( + t, tc.expectSubscribeCalls, + stateClient.subscribeCalls, + ) + require.True(t, unlockerCleaned) + require.True(t, stateCleaned) + require.Equal(t, 1, contextCalls) + + // Verify the unlock request fields when applicable. + if tc.expectReq != nil { + require.NotNil(t, unlocker.lastReq) + require.Equal(t, tc.expectReq.WalletPassword, + unlocker.lastReq.WalletPassword) + require.Equal(t, tc.expectReq.RecoveryWindow, + unlocker.lastReq.RecoveryWindow) + require.Equal(t, tc.expectReq.StatelessInit, + unlocker.lastReq.StatelessInit) + } else { + require.Nil(t, unlocker.lastReq) + } + + // Verify SubscribeState requests were well-formed. + require.Len( + t, stateClient.subscribeInputs, + stateClient.subscribeCalls, + ) + for _, req := range stateClient.subscribeInputs { + require.NotNil(t, req) + require.Equal( + t, &lnrpc.SubscribeStateRequest{}, req, + ) + } + }) + } +} diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index c5bb1923b7..2d40781a32 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -109,7 +109,7 @@ ## Breaking Changes * [Increased MinCLTVDelta from 18 to - 24](https://github.com/lightningnetwork/lnd/pull/TODO) to provide a larger + 24](https://github.com/lightningnetwork/lnd/pull/10331) to provide a larger safety margin above the `DefaultFinalCltvRejectDelta` (19 blocks). This affects users who create invoices with custom `cltv_expiry_delta` values between 18-23, which will now require a minimum of 24. The default value of diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index a98a4ebb16..f9a869dfa4 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -69,6 +69,12 @@ channel that was only expected to be used for a single message. The erring goroutine would block on the second send, leading to a deadlock at shutdown. +* [Fixed `lncli unlock` to wait until the wallet is ready to be + unlocked](https://github.com/lightningnetwork/lnd/pull/10536) + before sending the unlock request. The command now reports wallet state + transitions during startup, avoiding lost unlocks during slow database + initialization. + # New Features - Basic Support for [onion messaging forwarding](https://github.com/lightningnetwork/lnd/pull/9868)