Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/release-notes/release-notes-0.21.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@
* routerrpc HTLC event subscribers now receive specific failure details for
invoice-level validation failures, avoiding ambiguous `UNKNOWN` results. [#10520](https://github.com/lightningnetwork/lnd/pull/10520)

* [A new `wallet_synced` field has been
added](https://github.com/lightningnetwork/lnd/pull/10507) to the `GetInfo`
RPC response. This field indicates whether the wallet is fully synced to the
best chain, providing the wallet's internal sync state independently from the
composite `synced_to_chain` field which also considers router and blockbeat
dispatcher states.

## lncli Updates

## Breaking Changes
Expand Down
3 changes: 3 additions & 0 deletions itest/list_on_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,9 @@ func init() {
allTestCases = appendPrefixed(
"wallet", allTestCases, walletTestCases,
)
allTestCases = appendPrefixed(
"wallet sync", allTestCases, walletSyncTestCases,
)
allTestCases = appendPrefixed(
"coop close with external delivery", allTestCases,
coopCloseWithExternalTestCases,
Expand Down
71 changes: 71 additions & 0 deletions itest/lnd_wallet_sync_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package itest

import (
"time"

"github.com/lightningnetwork/lnd/lntest"
"github.com/stretchr/testify/require"
)

// walletSyncTestCases defines a set of tests for the wallet_synced field
// in GetInfoResponse.
var walletSyncTestCases = []*lntest.TestCase{
{
Name: "wallet synced",
TestFunc: runTestWalletSynced,
},
}

// runTestWalletSynced tests that the wallet_synced field in GetInfoResponse
// correctly reflects the wallet's sync state. It verifies that wallet_synced
// is false while the wallet is catching up to new blocks, and becomes true
// once fully synced.
func runTestWalletSynced(ht *lntest.HarnessTest) {
// Create a test node.
alice := ht.NewNodeWithCoins("Alice", nil)

// Verify wallet starts synced.
resp := alice.RPC.GetInfo()
require.True(ht, resp.WalletSynced)
ht.Logf("Alice wallet_synced=%v", resp.WalletSynced)

// Stop Alice to create a clear sync gap while we mine blocks.
require.NoError(ht, alice.Stop(), "failed to stop Alice")

// Mine blocks while Alice is offline.
const numBlocks = 40
ht.Miner().MineBlocks(numBlocks)
_, minerHeight := ht.Miner().GetBestBlock()

// Restart Alice without waiting for full chain sync.
require.NoError(
ht, alice.Start(ht.Context()), "failed to restart Alice",
)

// While Alice is behind the miner height, wallet_synced must be false.
deadline := time.Now().Add(lntest.DefaultTimeout)
for {
resp := alice.RPC.GetInfo()
if int32(resp.BlockHeight) >= minerHeight {
break
}

require.Falsef(ht, resp.WalletSynced,
"wallet_synced=true while behind "+
"(nodeHeight=%v, minerHeight=%v)",
resp.BlockHeight, minerHeight)

if time.Now().After(deadline) {
require.Fail(ht, "timed out waiting for "+
"node to catch up")
}

time.Sleep(50 * time.Millisecond)
}

// Final verification that wallet_synced is true.
require.Eventually(ht, func() bool {
return alice.RPC.GetInfo().WalletSynced
}, lntest.DefaultTimeout, 200*time.Millisecond,
"wallet should be synced after waiting")
}
4,922 changes: 2,467 additions & 2,455 deletions lnrpc/lightning.pb.go

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions lnrpc/lightning.proto
Original file line number Diff line number Diff line change
Expand Up @@ -2118,6 +2118,10 @@ message GetInfoResponse {

// Indicates whether final htlc resolutions are stored on disk.
bool store_final_htlc_resolutions = 22;

// Whether the wallet is fully synced to the best chain. This indicates the
// wallet's internal sync state with the backing chain source.
bool wallet_synced = 23;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we expose the block at which the wallet is?

uint32 wallet_block_height = 24;
string wallet_block_hash = 25;

Then we can track this field to reach the block we're handling deposits for:

blockSub := RegisterBlockEpochNtfn()
block := <-blockSub

info := GetInfo()
for info.WalletBlockHeight < block.Height {
  sleep
  info = GetInfo()
}

ListUnspent()

Copy link
Collaborator Author

@hieblmi hieblmi Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you look at how the newly introduced field is put together you land at

func (b *BtcWallet) IsSynced() (bool, int64, error) {
which already does compare against the best known block height.
So your example would collapse to

for {
  info := GetInfo()
  if info.WalletSynced { break }
  sleep
}

ListUnspent()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about hooking this into the existing state subscription service?

enum WalletState {
// NON_EXISTING means that the wallet has not yet been initialized.
NON_EXISTING = 0;
// LOCKED means that the wallet is locked and requires a password to unlock.
LOCKED = 1;
// UNLOCKED means that the wallet was unlocked successfully, but RPC server
// isn't ready.
UNLOCKED = 2;
// RPC_ACTIVE means that the lnd server is active but not fully ready for
// calls.
RPC_ACTIVE = 3;
// SERVER_ACTIVE means that the lnd server is ready to accept calls.
SERVER_ACTIVE = 4;
// WAITING_TO_START means that node is waiting to become the leader in a
// cluster and is not started yet.
WAITING_TO_START = 255;
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds like it would fit there, but the StateService which exposes this state lives in the rpcperms which doesn't have access to the chain state (yet), so I think the wallet sync state is better exposed in the rpcserver.go where we do already have access.

}

message GetDebugInfoRequest {
Expand Down
4 changes: 4 additions & 0 deletions lnrpc/lightning.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -5556,6 +5556,10 @@
"store_final_htlc_resolutions": {
"type": "boolean",
"description": "Indicates whether final htlc resolutions are stored on disk."
},
"wallet_synced": {
"type": "boolean",
"description": "Whether the wallet is fully synced to the best chain. This indicates the\nwallet's internal sync state with the backing chain source."
}
}
},
Expand Down
21 changes: 14 additions & 7 deletions rpcserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -3450,6 +3450,7 @@ func (r *rpcServer) GetInfo(_ context.Context,
Features: features,
RequireHtlcInterceptor: r.cfg.RequireInterceptor,
StoreFinalHtlcResolutions: r.cfg.StoreFinalHtlcResolutions,
WalletSynced: syncInfo.isWalletSynced,
}, nil
}

Expand Down Expand Up @@ -9468,6 +9469,10 @@ type chainSyncInfo struct {
// - blockbeat dispatcher.
isSynced bool

// isWalletSynced specifies whether the wallet is synced to
// our chain view.
isWalletSynced bool

// bestHeight is the current height known to the chain backend.
bestHeight int32

Expand All @@ -9488,22 +9493,23 @@ func (r *rpcServer) getChainSyncInfo() (*chainSyncInfo, error) {
return nil, fmt.Errorf("unable to get best block info: %w", err)
}

isSynced, bestHeaderTimestamp, err := r.server.cc.Wallet.IsSynced()
isWalletSynced, bestHeaderTimestamp, err :=
r.server.cc.Wallet.IsSynced()
if err != nil {
return nil, fmt.Errorf("unable to sync PoV of the wallet "+
"with current best block in the main chain: %v", err)
}

// Create an info to be returned.
// Create info to be returned.
info := &chainSyncInfo{
isSynced: isSynced,
bestHeight: bestHeight,
blockHash: *bestHash,
timestamp: bestHeaderTimestamp,
isWalletSynced: isWalletSynced,
bestHeight: bestHeight,
blockHash: *bestHash,
timestamp: bestHeaderTimestamp,
}

// Exit early if the wallet is not synced.
if !isSynced {
if !isWalletSynced {
rpcsLog.Debugf("Wallet is not synced to height %v yet",
bestHeight)

Expand All @@ -9518,6 +9524,7 @@ func (r *rpcServer) getChainSyncInfo() (*chainSyncInfo, error) {
// by many wallets (and also our itests) to make sure everything's up to
// date, we add the router's state to it. So the flag will only toggle
// to true once the router was also able to catch up.
isSynced := isWalletSynced
if !r.cfg.Routing.AssumeChannelValid {
routerHeight := r.server.graphBuilder.SyncedHeight()
isSynced = uint32(bestHeight) == routerHeight
Expand Down
Loading