From e865552cc12740abd4b32f1fd8b555d6313a4887 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 1 Feb 2026 23:20:00 -0500 Subject: [PATCH 1/4] lncli unlock: wait until daemon can unlock Use the StateService stream to wait for LOCKED before sending the unlock request, then wait for UNLOCKED/RPC_ACTIVE before reporting success. If the state shows the wallet is already unlocked, skip sending the unlock request and return an error immediately. This avoids lost unlocks during slow startup. Fix https://github.com/lightningnetwork/lnd/issues/7749 --- cmd/commands/cmd_walletunlocker.go | 159 +++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/cmd/commands/cmd_walletunlocker.go b/cmd/commands/cmd_walletunlocker.go index c396a038cf8..adb6efe45ef 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 ( @@ -507,6 +512,10 @@ func unlock(ctx *cli.Context) error { client, cleanUp := getWalletUnlockerClient(ctx) defer cleanUp() + // Use the always-on state service to wait for unlock readiness. + stateClient, stateCleanUp := getStateServiceClient(ctx) + defer stateCleanUp() + var ( pw []byte err error @@ -555,11 +564,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 +595,137 @@ 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", From 6cd785c7518d13788326260bd2f2dcffd1714b4a Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 2 Feb 2026 00:42:34 -0500 Subject: [PATCH 2/4] lncli unlock: add unit tests Add table-driven unit tests for unlock() that exercise success and error paths, cover flag and arg handling. --- cmd/commands/cmd_walletunlocker.go | 28 +- cmd/commands/cmd_walletunlocker_test.go | 661 ++++++++++++++++++++++++ 2 files changed, 684 insertions(+), 5 deletions(-) create mode 100644 cmd/commands/cmd_walletunlocker_test.go diff --git a/cmd/commands/cmd_walletunlocker.go b/cmd/commands/cmd_walletunlocker.go index adb6efe45ef..0c04ce25c0d 100644 --- a/cmd/commands/cmd_walletunlocker.go +++ b/cmd/commands/cmd_walletunlocker.go @@ -507,13 +507,30 @@ 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 := getStateServiceClient(ctx) + stateClient, stateCleanUp := getStateClientFn(ctx) defer stateCleanUp() var ( @@ -526,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. @@ -536,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 @@ -614,6 +631,7 @@ func waitForWalletState(ctx context.Context, client lnrpc.StateClient, return errors.New("lnd shut down before " + "reaching expected wallet state") } + return err } diff --git a/cmd/commands/cmd_walletunlocker_test.go b/cmd/commands/cmd_walletunlocker_test.go new file mode 100644 index 00000000000..91e4be91368 --- /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, + ) + } + }) + } +} From 593e8c01cfa225d907bb1cade7ace923d812312e Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 1 Feb 2026 23:53:54 -0500 Subject: [PATCH 3/4] docs/release-notes: fix PR reference --- docs/release-notes/release-notes-0.20.1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index c5bb1923b73..2d40781a322 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 From d8f39fa1b4492ee34cc90d44bb75845b751addae Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 1 Feb 2026 23:51:46 -0500 Subject: [PATCH 4/4] docs/release-notes: add release notes entry --- docs/release-notes/release-notes-0.21.0.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index a98a4ebb16a..f9a869dfa41 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)