diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 56991bd3f..9143238b0 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -4470,6 +4470,50 @@ func (lc *LightningChannel) ReceiveNewCommitment(commitSig lnwire.Sig, return nil } +// IsChannelClean returns true if neither side has pending commitments, neither +// side has HTLC's, and all updates are locked in irrevocably. Internally, it +// utilizes the oweCommitment function by calling it for local and remote +// evaluation. We check if we have a pending commitment for our local state +// since this function may be called by sub-systems that are not the link (e.g. +// the rpcserver), and the ReceiveNewCommitment & RevokeCurrentCommitment calls +// are not atomic, even though link processing ensures no updates can happen in +// between. +func (lc *LightningChannel) IsChannelClean() bool { + lc.RLock() + defer lc.RUnlock() + + // Check whether we have a pending commitment for our local state. + if lc.localCommitChain.hasUnackedCommitment() { + return false + } + + // Check whether our counterparty has a pending commitment for their + // state. + if lc.remoteCommitChain.hasUnackedCommitment() { + return false + } + + // We call ActiveHtlcs to ensure there are no HTLCs on either + // commitment. + if len(lc.channelState.ActiveHtlcs()) != 0 { + return false + } + + // Now check that both local and remote commitments are signing the + // same updates. + if lc.oweCommitment(true) { + return false + } + + if lc.oweCommitment(false) { + return false + } + + // If we reached this point, the channel has no HTLCs and both + // commitments sign the same updates. + return true +} + // OweCommitment returns a boolean value reflecting whether we need to send // out a commitment signature because there are outstanding local updates and/or // updates in the local commit tx that aren't reflected in the remote commit tx diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go index 6f190ade7..c201cc787 100644 --- a/lnwallet/channel_test.go +++ b/lnwallet/channel_test.go @@ -9669,3 +9669,150 @@ func TestMayAddOutgoingHtlcZeroValue(t *testing.T) { require.NoError(t, aliceChannel.MayAddOutgoingHtlc()) require.NoError(t, bobChannel.MayAddOutgoingHtlc()) } + +// TestIsChannelClean tests that IsChannelClean returns the expected values +// in different channel states. +func TestIsChannelClean(t *testing.T) { + t.Parallel() + + aliceChannel, bobChannel, cleanUp, err := CreateTestChannels( + channeldb.ZeroHtlcTxFeeBit, + ) + require.NoError(t, err) + defer cleanUp() + + // Channel state should be clean at the start of the test. + assertCleanOrDirty(true, aliceChannel, bobChannel, t) + + // Assert that neither side considers the channel clean when alice + // sends an htlc. + // ---add---> + htlc, preimage := createHTLC(0, lnwire.MilliSatoshi(5000000)) + _, err = aliceChannel.AddHTLC(htlc, nil) + require.NoError(t, err) + _, err = bobChannel.ReceiveHTLC(htlc) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // Assert that the channel remains dirty until the HTLC is completely + // removed from both commitments. + + // ---sig---> + aliceSig, aliceHtlcSigs, _, err := aliceChannel.SignNextCommitment() + require.NoError(t, err) + err = bobChannel.ReceiveNewCommitment(aliceSig, aliceHtlcSigs) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // <---rev--- + bobRevocation, _, err := bobChannel.RevokeCurrentCommitment() + require.NoError(t, err) + _, _, _, _, err = aliceChannel.ReceiveRevocation(bobRevocation) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // <---sig--- + bobSig, bobHtlcSigs, _, err := bobChannel.SignNextCommitment() + require.NoError(t, err) + err = aliceChannel.ReceiveNewCommitment(bobSig, bobHtlcSigs) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // ---rev---> + aliceRevocation, _, err := aliceChannel.RevokeCurrentCommitment() + require.NoError(t, err) + _, _, _, _, err = bobChannel.ReceiveRevocation(aliceRevocation) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // <--settle-- + err = bobChannel.SettleHTLC(preimage, 0, nil, nil, nil) + require.NoError(t, err) + err = aliceChannel.ReceiveHTLCSettle(preimage, 0) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // <---sig--- + bobSig, bobHtlcSigs, _, err = bobChannel.SignNextCommitment() + require.NoError(t, err) + err = aliceChannel.ReceiveNewCommitment(bobSig, bobHtlcSigs) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // ---rev---> + aliceRevocation, _, err = aliceChannel.RevokeCurrentCommitment() + require.NoError(t, err) + _, _, _, _, err = bobChannel.ReceiveRevocation(aliceRevocation) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // ---sig---> + aliceSig, aliceHtlcSigs, _, err = aliceChannel.SignNextCommitment() + require.NoError(t, err) + err = bobChannel.ReceiveNewCommitment(aliceSig, aliceHtlcSigs) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // <---rev--- + bobRevocation, _, err = bobChannel.RevokeCurrentCommitment() + require.NoError(t, err) + _, _, _, _, err = aliceChannel.ReceiveRevocation(bobRevocation) + require.NoError(t, err) + assertCleanOrDirty(true, aliceChannel, bobChannel, t) + + // Now we check that update_fee is handled and state is dirty until it + // is completely locked in. + // ---fee---> + fee := chainfee.SatPerKWeight(333) + err = aliceChannel.UpdateFee(fee) + require.NoError(t, err) + err = bobChannel.ReceiveUpdateFee(fee) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // ---sig---> + aliceSig, aliceHtlcSigs, _, err = aliceChannel.SignNextCommitment() + require.NoError(t, err) + err = bobChannel.ReceiveNewCommitment(aliceSig, aliceHtlcSigs) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // <---rev--- + bobRevocation, _, err = bobChannel.RevokeCurrentCommitment() + require.NoError(t, err) + _, _, _, _, err = aliceChannel.ReceiveRevocation(bobRevocation) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // <---sig--- + bobSig, bobHtlcSigs, _, err = bobChannel.SignNextCommitment() + require.NoError(t, err) + err = aliceChannel.ReceiveNewCommitment(bobSig, bobHtlcSigs) + require.NoError(t, err) + assertCleanOrDirty(false, aliceChannel, bobChannel, t) + + // The state should finally be clean after alice sends her revocation. + // ---rev---> + aliceRevocation, _, err = aliceChannel.RevokeCurrentCommitment() + require.NoError(t, err) + _, _, _, _, err = bobChannel.ReceiveRevocation(aliceRevocation) + require.NoError(t, err) + assertCleanOrDirty(true, aliceChannel, bobChannel, t) +} + +// assertCleanOrDirty is a helper function that asserts that both channels are +// clean if clean is true, and dirty if clean is false. +func assertCleanOrDirty(clean bool, alice, bob *LightningChannel, + t *testing.T) { + + t.Helper() + + if clean { + require.True(t, alice.IsChannelClean()) + require.True(t, bob.IsChannelClean()) + return + } + + require.False(t, alice.IsChannelClean()) + require.False(t, bob.IsChannelClean()) +}