What changed, and why it matters
This change improves the 'lncli unlock' command so it waits for the LND daemon to be ready before sending the wallet password. Previously, if the daemon was still starting up, the unlock command could be sent too early and silently fail or get lost, leaving the wallet locked. Now it checks the daemon's state stream, sends the unlock only when the wallet is in LOCKED state, and confirms the wallet reaches UNLOCKED before saying success. It is a reliability/usability fix rather than a direct exploit fix.
No urgent security action required. This is a reliability improvement. Operators should update lncli as part of normal LND release cycle. If running automated unlock scripts, verify they handle the new error cases (already unlocked, wallet not initialized).
Security signals we found
Fixes a race condition where unlock requests could be lost during daemon startup
Adds state validation before and after sensitive wallet unlock operation
Prevents sending unlock request when wallet is already unlocked
Uses always-on StateService stream rather than blind RPC call
No cryptographic changes, privilege changes, or network exposure introduced
Evidence from the diff
The patch modifies cmd/commands/cmd_walletunlocker.go to use lnrpc.StateClient.SubscribeState before and after calling UnlockWallet. It adds waitForWalletLocked to block until WalletState_LOCKED, returning errors for NON_EXISTING or already-unlocked states. It adds waitForWalletUnlocked to block until WalletState_UNLOCKED/RPC_ACTIVE/SERVER_ACTIVE. Both helpers degrade gracefully if StateService returns Unimplemented or Unavailable. This addresses issue #7749 (‘lost unlocks during slow startup’).
Changed components
cmd/commands/cmd_walletunlocker.golncli unlock CLI commandwallet unlocker RPC flowInspect captured patch +159 / −0
diff --git a/cmd/commands/cmd_walletunlocker.go b/cmd/commands/cmd_walletunlocker.go
index c396a03..adb6efe 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",
Why this scored 26/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.