Skip to content

Add NoopAdd HTLCs #9871

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

GeorgeTsagk
Copy link
Collaborator

Description

Adds a new type of HTLC named "noop add", which behaves identically to normal HTLCs except for the settling part. If upon settlement the receiver has an above dust balance, then the amount is returned back to the sender and the only thing that ends up being updated is the aux leaf of the commitment, which will successfully reflect any overlay changes.

Replacement for #9430

@GeorgeTsagk GeorgeTsagk self-assigned this May 27, 2025
Copy link
Contributor

coderabbitai bot commented May 27, 2025

Important

Review skipped

Auto reviews are limited to specific labels.

🏷️ Labels to auto review (1)
  • llm-review

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@ziggie1984
Copy link
Collaborator

NACK, in my opinion we should be very careful progressing in this direction:

  1. This needs proper documentation, what is the exact use case, to understand the reasoning behind this you have to know the specifics of the TapAss design

  2. Looks like a bandaid to the ChannelState maschine a very crucial part of the LND software, it needs way more tests to also verify normal behaviour is not touched

  3. Moreover the case where we currently check if the amount is above the reserve (and treat it as a NOOP or an ADD) does not work, and should be observable via itests, from different perspectives we would credit different parties. So the so called NOOP is an ADD in some cases which is super confusing.

@GeorgeTsagk
Copy link
Collaborator Author

NACK, in my opinion we should be very careful progressing in this direction:

  1. This needs proper documentation, what is the exact use case, to understand the reasoning behind this you have to know the specifics of the TapAss design
  2. Looks like a bandaid to the ChannelState maschine a very crucial part of the LND software, it needs way more tests to also verify normal behaviour is not touched
  3. Moreover the case where we currently check if the amount is above the reserve (and treat it as a NOOP or an ADD) does not work, and should be observable via itests, from different perspectives we would credit different parties. So the so called NOOP is an ADD in some cases which is super confusing.
  1. Will add proper documentation, thanks for pointing out.
  2. Will add some unit coverage that exposes the noop_add path, as far as preserving the old behavior isn't the existing unit coverage enough? Unless what you're saying is that we need to run the entire coverage but with noop_add enabled?
  3. What do you mean by "does not work"? This behavior is currently only exposed when running LND side by side with Tapd, see the LiT itest PR where most of payments default to noop_add HTLCs (it's tapd that sets the TLV flag for LND to flag it as noop). Yeap it's true that a noop can be a normal add and that's if the receiver is below the chan reserve.

Thanks for your attention and the review so far
Will address all of the above points

Comment on lines 3028 to 3045
if channel.BalanceAboveReserve(party) {
d := int64(entry.Amount)
balanceDeltas.ModifyForParty(
party.CounterParty(),
func(acc int64) int64 {
return acc + d
},
)
} else {
d := int64(entry.Amount)
balanceDeltas.ModifyForParty(
party,
func(acc int64) int64 {
return acc + d
},
)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This probably does work at least for LND's implementation but it does not have to because the ChannelState maschine for local and remote can diverge example:

  1. Local and Remote Commitment can diverge what you are now doing is the following:
    Imagine Node A and Node B, Node B starts the channel with 0 balance.
    a) Now A sends Node B and UpdateAdd (with NOOP set) and a CommitSig + Node B sends A the Revoke. All good so far. Node B is still below Reserve on both Node A's commitment and B's.
    b) Node B send UpdateFullFill to Node A (NOOP ADD was credited to Node B, because B is below Reserve).
    c) Node B sends also A the CommitSig for this new State. For Node A B is above reserve now on the local commitment.
    d) Node A before sending Revoking his old state, sends another NOOP-ADD (everything works concurrently although for LND it will always immediately send the revoke).
    e) Node B receives the New NOOP-ADD from A and will still credit it to Node A because when he settles it, he still is below is reserve on the local commitment, although he is already above the reserve on Node A.

Node A does look at it at the same way so both sides will do the same and the change works, but as mentioned above there are no guarantees that this will be done by other implementations. And I think it is super hard to document with all this "Ifs".

We might find a more lean way to do what you are describing. I think it is worth trying to investigate other design choices. This is a quick solution but it brings high burden probably to maintain in the future.

Copy link
Member

Choose a reason for hiding this comment

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

e) Node B receives the New NOOP-ADD from A and will still credit it to Node A because when he settles it, he still is below is reserve on the local commitment, although he is already above the reserve on Node A.

Are you also assuming Node A sends a sig right after the noop add? As only once a sig is received will the views be evaluated to (maybe) apply the noop add.

IIUC, you're referring to the case where balances can diverge momentarily due to concurrent commit sig sends by both parties?

but as mentioned above there are no guarantees that this will be done by other implementations.

This is intended to be isolated to just aux channels. It doesn't solve a problem otherwise. As I commented below, we can gate this feature (reject commit if violated) on if the channel is an aux channel or not.

Copy link
Member

Choose a reason for hiding this comment

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

We might find a more lean way to do what you are describing. I think it is worth trying to investigate other design choices. This is a quick solution but it brings high burden probably to maintain in the future.

The original stab at this involved the old trick we came up with way back to not have dust HTLCs at all: each send is actually two HTLCs, one in either direction, that nets out to the same asset balance. We started to implement that, but threw it out, as it has implications to the force close logic: an extra set of HTLCs to resolve when we go on chain per outstanding payment.

The high level problem this solves is that: sometimes you want to have an HTLC exist in the commitment transaction/logs, but not actually apply it. Just by existing, it may anchor some useful information for a higher level protocol.

This noop idea was attractive, as the diff is relatively small (can be made smaller with my comment above). It also doesn't change any of the chain handling logic. The trade off is that in the case of a force close, we can't do the noop trick off-chain, so the funds will go to the settler.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Are you also assuming Node A sends a sig right after the noop add? As only once a sig is received will the views be evaluated to (maybe) apply the noop add.

Yes as far as I can tell there is no restriction that Alice needs to revoke her old commitment but just can send UpdatAdd + CommitSig to NodeB which as a consequence will always stay below the reserve on this commitment. As mentioned it will work with the current implementation but it will basically have this requirement which eventually we need to decide whether we are content with:

"A NOOPADD will add an HTLC to the receiving side if the Receiving Side is below it's channel reserve on the Receiving Commitment Transaction (Applies ONLY for Aux Channels)".

This is intended to be isolated to just aux channels. It doesn't solve a problem otherwise. As I commented below, we can gate this feature (reject commit if violated) on if the channel is an aux channel or not.#

Restricting it only to Aux Channels is definitely the way if we go. this route.

The original stab at this involved the old trick we came up with way back to not have dust HTLCs at all: each send is actually two HTLCs, one in either direction, that nets out to the same asset balance. We started to implement that, but threw it out, as it has implications to the force close logic: an extra set of HTLCs to resolve when we go on chain per outstanding payment.

Ahh ok yeah this does not seem better from a design perspective.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Local and Remote Commitment can diverge what you are now doing is the following:
Imagine Node A and Node B, Node B starts the channel with 0 balance.
a) Now A sends Node B and UpdateAdd (with NOOP set) and a CommitSig + Node B sends A the Revoke. All good so far. Node B is still below Reserve on both Node A's commitment and B's.
b) Node B send UpdateFullFill to Node A (NOOP ADD was credited to Node B, because B is below Reserve).
c) Node B sends also A the CommitSig for this new State. For Node A B is above reserve now on the local commitment.
d) Node A before sending Revoking his old state, sends another NOOP-ADD (everything works concurrently although for LND it will always immediately send the revoke).
e) Node B receives the New NOOP-ADD from A and will still credit it to Node A because when he settles it, he still is below is reserve on the local commitment, although he is already above the reserve on Node A.

Not sure if your example still applies if you don't consult your local commitment for the remote party's balance, but the remote one instead?
This is what the BalanceAboveReserve helper does now

lnd/channeldb/channel.go

Lines 2139 to 2145 in b3831da

case party.IsLocal():
return c.LocalCommitment.LocalBalance.ToSatoshis() >
c.LocalChanCfg.ChanReserve
case party.IsRemote():
return c.RemoteCommitment.RemoteBalance.ToSatoshis() >
c.RemoteChanCfg.ChanReserve

Copy link
Member

Choose a reason for hiding this comment

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

Yes as far as I can tell there is no restriction that Alice needs to revoke her old commitment but just can send UpdatAdd + CommitSig to NodeB which as a consequence will always stay below the reserve on this commitment. As mentioned it will work with the current implementation but it will basically have this requirement which eventually we need to decide whether we are content with:

You're correct that an implementation can send a signature before revoking their existing commitment after receiving a signature. You can only do this once though, as you can't receive any other new states until you revoke.

As for the reserve, the aux channels will eventually have their own higher level reserve enforcement. So this allows the reserve to be much lower, even zero cone this is in place.

Restricting it only to Aux Channels is definitely the way if we go. this route.

Yep, this has always been the deployment path. This feature won't be activated for anything other than the set of aux channels.

if _, ok := customRecords[noopTLV]; ok {
entryType = NoopAdd
}

Copy link
Member

Choose a reason for hiding this comment

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

There isn't an error path here yet, but we should return an error if this type is used for any channel type other than the aux channels. This'll serve to contain the logic a bit.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Why error out instead of no-op'ing the noop?

i.e

		if lc.ChanType().HasTapscriptRoot() {
			entryType = NoopAdd
		}

if this for any reason is happening over a normal channel, the TLV field will be ignored and won't change any state whatsoever

Copy link
Collaborator

Choose a reason for hiding this comment

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

if that happens to a normal channel which does not understand the TLV, you propose just treating it as a normal Add ? But I think we should error the channel if a peer tries to trick us into adding a NOOPADD for a normal channel.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

if that happens to a normal channel which does not understand the TLV, you propose just treating it as a normal Add

Yes, if for any reason (maybe even by accident) the noop gets signaled then we should simply revert to old behavior.

But I think we should error the channel if a peer tries to trick us into adding a NOOPADD for a normal channel.

Need to account for potential bugs in software. If for any reason the flag "escapes" the scope of being set only over custom channels we wouldn't want to cause force-closes.

Copy link
Member

Choose a reason for hiding this comment

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

Why error out instead of no-op'ing the noop?

To make the failure explicit, as it should never happen. If we silently downgrade an unexpected noop HTLC (say from a legacy static key channel), then it may be harder to figure out what happened after the fact.

We can put this check in validateCommitmentSanity, as it's already possible that we'll error out if a peer or the switch sends us a bad/invalid HTLC.

Copy link
Member

Choose a reason for hiding this comment

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

Need to account for potential bugs in software. If for any reason the flag "escapes" the scope of being set only over custom channels we wouldn't want to cause force-closes.

If that's the case we should let the FC happen so we know something is wrong, otherwise the bug will be sitting there silently forever.

Comment on lines 3028 to 3045
if channel.BalanceAboveReserve(party) {
d := int64(entry.Amount)
balanceDeltas.ModifyForParty(
party.CounterParty(),
func(acc int64) int64 {
return acc + d
},
)
} else {
d := int64(entry.Amount)
balanceDeltas.ModifyForParty(
party,
func(acc int64) int64 {
return acc + d
},
)
}
Copy link
Member

Choose a reason for hiding this comment

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

e) Node B receives the New NOOP-ADD from A and will still credit it to Node A because when he settles it, he still is below is reserve on the local commitment, although he is already above the reserve on Node A.

Are you also assuming Node A sends a sig right after the noop add? As only once a sig is received will the views be evaluated to (maybe) apply the noop add.

IIUC, you're referring to the case where balances can diverge momentarily due to concurrent commit sig sends by both parties?

but as mentioned above there are no guarantees that this will be done by other implementations.

This is intended to be isolated to just aux channels. It doesn't solve a problem otherwise. As I commented below, we can gate this feature (reject commit if violated) on if the channel is an aux channel or not.

Comment on lines 3028 to 3045
if channel.BalanceAboveReserve(party) {
d := int64(entry.Amount)
balanceDeltas.ModifyForParty(
party.CounterParty(),
func(acc int64) int64 {
return acc + d
},
)
} else {
d := int64(entry.Amount)
balanceDeltas.ModifyForParty(
party,
func(acc int64) int64 {
return acc + d
},
)
}
Copy link
Member

Choose a reason for hiding this comment

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

We might find a more lean way to do what you are describing. I think it is worth trying to investigate other design choices. This is a quick solution but it brings high burden probably to maintain in the future.

The original stab at this involved the old trick we came up with way back to not have dust HTLCs at all: each send is actually two HTLCs, one in either direction, that nets out to the same asset balance. We started to implement that, but threw it out, as it has implications to the force close logic: an extra set of HTLCs to resolve when we go on chain per outstanding payment.

The high level problem this solves is that: sometimes you want to have an HTLC exist in the commitment transaction/logs, but not actually apply it. Just by existing, it may anchor some useful information for a higher level protocol.

This noop idea was attractive, as the diff is relatively small (can be made smaller with my comment above). It also doesn't change any of the chain handling logic. The trade off is that in the case of a force close, we can't do the noop trick off-chain, so the funds will go to the settler.

@GeorgeTsagk
Copy link
Collaborator Author

GeorgeTsagk commented Jun 11, 2025

Pushed a version which adds more docs, and simplifies the crediting logic

Also in order to avoid violating the chan reserve check I now take into account the accumulated balance delta for the party we're performing the check for. So now we check if balance + delta exceeds reserve, to make the call on if we apply the noop.

I added the "failure" path which reverts to old behavior

lnd/lnwallet/channel.go

Lines 10087 to 10100 in 0808083

// shouldSetNoop checks the custom records of the entry and the channel type and
// returns a boolean indicating whether this add entry should be converted to a
// noop add type. This will only return true if the TLV field signalling the use
// of a noop HTLC is set, and the channel has a custom tapscript root.
func shouldSetNoop(records lnwire.CustomRecords,
chanType channeldb.ChannelType) bool {
noopTLV := uint64(NoopHtlcType.TypeVal())
if _, ok := records[noopTLV]; ok && chanType.HasTapscriptRoot() {
return true
}
return false
}


// TestNoopAddSettle tests that adding and settling an HTLC with no-op, no
// balances are actually affected.
func TestNoopAddSettle(t *testing.T) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

could you also add a test which tests the case with the reserve limit ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Great recommendation, added a test that exposes the behavior around the channel reserve

Copy link
Collaborator

Choose a reason for hiding this comment

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

Combined with my previous comment (about extracting the logic into a standalone function), I think we should do a table-driven test here that's as exhaustive of edge cases as possible. We really don't want to ever run into an integer underflow or similar situation.

@ziggie1984
Copy link
Collaborator

I think this change before further work needs an design ACK from @yyforyongyu who has the most expertise in the htlcswitch package. So I would keep myself as a third reviewer.

@ziggie1984 ziggie1984 requested review from yyforyongyu and removed request for ziggie1984 June 16, 2025 15:46
@ZZiigguurraatt
Copy link

For any new TLV, I think they need to be properly documented in https://github.com/Roasbeef/blips/blob/tap-blip/blip-tap.md and any other relevant places. The taproot assets protocol is getting more and more hard to understand because of missing definitions (see also lightninglabs/taproot-assets#1446).

Copy link
Member

@Roasbeef Roasbeef left a comment

Choose a reason for hiding this comment

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

Digging the evolution of this change (from the initial ideas that we scrapped, etc). I think we need to add a retransmission test, to ensure that we aren't erroneously deleting the tlv record on the wire.

func (c *OpenChannel) BalanceAboveReserve(
party lntypes.ChannelParty, delta int64) bool {

c.Lock()
Copy link
Member

Choose a reason for hiding this comment

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

Can RLock.

Copy link
Member

Choose a reason for hiding this comment

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

Also why create the method here vs on the channel state machine (one layer up)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I need to acquire the locks to read the local commitment & channel config, so I added the method where the locks were readily available

@@ -4489,6 +4553,14 @@ func (lc *LightningChannel) ProcessChanSyncMsg(ctx context.Context,
// Next, we'll need to send over any updates we sent as part of
// this new proposed commitment state.
for _, logUpdate := range commitDiff.LogUpdates {
//nolint:ll
if htlc, ok := logUpdate.UpdateMsg.(*lnwire.UpdateAddHTLC); ok {
delete(htlc.CustomRecords, uint64(NoopHtlcType.TypeVal()))
Copy link
Member

Choose a reason for hiding this comment

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

Same comment as above, we need to make sure retransmission of the noop HTLC works properly. If our sig covers the HTLC as a noop, but we don't send the TLV in the add message, then we'll trigger a force closed based on signature mismatch.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I added a test case to TestChanSyncOweCommitment that uses the noop record in the appropriate channel type

After removing these leftover lines it seems to be working as expected, with the channel remaining alive and the balances being as expected

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Didn't want to add a whole new test specifically for noop HTLCs, as it would introduce a lot of unnecessary diff.

But if this test case is not sufficient in terms of coverage can always add more tests

@saubyk saubyk added this to the v0.20.0 milestone Jun 18, 2025
@saubyk saubyk added this to lnd v0.20 Jun 18, 2025
@saubyk saubyk moved this to In progress in lnd v0.20 Jun 18, 2025
@lightninglabs-deploy
Copy link

@GeorgeTsagk, remember to re-request review from reviewers when ready

Copy link
Collaborator

@guggero guggero left a comment

Choose a reason for hiding this comment

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

Did a first pass, mostly to load some context. Looks pretty contained, which is super nice.

Just to verify my understanding: The current version will still actually send and credit the HTLC to the receiver until the receiver has a satoshi balance over their reserve? And only then will it really become a no-op?
And if the no-op goes on chain, it looks and behaves the same way as a normal "add" HTLC?


// TestNoopAddSettle tests that adding and settling an HTLC with no-op, no
// balances are actually affected.
func TestNoopAddSettle(t *testing.T) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Combined with my previous comment (about extracting the logic into a standalone function), I think we should do a table-driven test here that's as exhaustive of edge cases as possible. We really don't want to ever run into an integer underflow or similar situation.

@Roasbeef
Copy link
Member

And if the no-op goes on chain, it looks and behaves the same way as a normal "add" HTLC?

Yes it should behave as a normal add HTLC. Parallel HTLCs will each have an non-dust output on the commitment transaction, but once settled off-chain, we'll credit the sender.

There's another optimization that we can do here to instead just aggregate many aux HTLCs into a single one progressively. The need to have similar time locks to do it safely though. Going even further, there's a more elaborate path where there's a single HTLC that commits to a tree of future HTLCs, CTV style. I think that's out of scope for now, as we'd want to add something like that to the BOLT specs themselves, as they're a way to effectively make commitment transactions never grow in size with additional HTLCs, which makes the process of just force closing much cheaper.

Copy link
Collaborator

@guggero guggero left a comment

Choose a reason for hiding this comment

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

Looks very good, almost there!

Copy link
Collaborator

@guggero guggero left a comment

Choose a reason for hiding this comment

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

LGTM 🎉

@yyforyongyu
Copy link
Member

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a new "noop add" HTLC type, which is a great feature for managing channel states without affecting balances under certain conditions. The implementation looks mostly correct, but I've found a critical bug related to unit conversion (msat vs sat) that could lead to incorrect balance calculations and behavior. I've also pointed out some areas where comments and naming could be improved for clarity. The tests for the new functionality are comprehensive but one of them seems to confirm the buggy behavior and will need to be updated along with the fix.

party lntypes.ChannelParty, balanceDeltas *lntypes.Dual[int64]) {

channel := lc.channelState
delta := btcutil.Amount(balanceDeltas.GetForParty(party))

Choose a reason for hiding this comment

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

critical

There's a critical bug here concerning unit conversion. The balanceDeltas map stores values in milli-satoshis, but balanceDeltas.GetForParty(party) is directly cast to btcutil.Amount, which is in satoshis. This means milli-satoshis are being treated as satoshis, which will lead to incorrect behavior of balanceAboveReserve.

For example, a delta of 25001 msat will be treated as 25001 sat, which is 1000x larger.

To fix this, you should convert the milli-satoshi value to satoshis before casting.

Additionally, the test TestEvaluateNoOpHtlc in lnwallet/channel_test.go appears to be flawed as it confirms the current buggy behavior. It should be updated along with this fix.

Suggested change
delta := btcutil.Amount(balanceDeltas.GetForParty(party))
delta := lnwire.MilliSatoshi(balanceDeltas.GetForParty(party)).ToSatoshis()

Copy link
Collaborator

Choose a reason for hiding this comment

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

Wow, that's actually true... I don't understand how we introduced the lntypes.Dual but then used int64 for the balanceDeltas. I think we should add a refactor commit to this PR that changes that to lnwire.MilliSatoshi, that would've made it way more clear, cc @GeorgeTsagk.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We can add a local type alias (for example type msat = lnwire.MilliSatoshi to keep things short in this part of the code.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh, interesting, this was introduced by addressing a previous comment

Copy link
Collaborator

@guggero guggero left a comment

Choose a reason for hiding this comment

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

See inline comment about int64 vs. lnwire.MilliSatoshi.

@GeorgeTsagk
Copy link
Collaborator Author

Pushed a version that addresses the critical bug reported here

I correctly cast the delta value and adjust the balanceAboveReserve calculation accordingly

Enhanced the table driven tests for evaluateNoOpHTLC with cases where we have negative deltas too

@GeorgeTsagk GeorgeTsagk requested a review from guggero July 3, 2025 11:53
Copy link
Collaborator

@guggero guggero left a comment

Choose a reason for hiding this comment

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

There's still a value underflow. Please think this through carefully and methodically (using a debugger and potentially better unit tests) before re-requesting review.

lnwire.NewMSatFromSatoshis(tc.remoteBalance)
}

aliceChan.evaluateNoOpHtlc(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Unfortunately this unit test doesn't seem to be able to actually catch the edge cases it should.
Perhaps we need to refactor the balanceAboveReserve to make sure no over or underflows happen.
Because currently there clearly is an invalid cast (delta := lnwire.MilliSatoshi(balanceDeltas.GetForParty(party))) that leads to a value overflow:
Screenshot From 2025-07-04 09-56-24

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Unfortunately this unit test doesn't seem to be able to actually catch the edge cases it should.

The table driven cases are pretty exhaustive at this point (after adding the negative deltas cases -- which is what should expose the above behavior)

The thing is that the underflow effectively caused the right subtraction result, that's why the tests were happy.
https://go.dev/play/p/DvsoAr8lUiv

Although this worked, I'd prefer not to rely on the underflow behavior of golang (even though well defined)

I switched this to be identical to how we handle negative deltas in a different part of existing codebase:

lnd/lnwallet/channel.go

Lines 4729 to 4738 in 76bdb1e

if deltas.Local >= 0 {
ourBalance += lnwire.MilliSatoshi(deltas.Local)
} else {
ourBalance -= lnwire.MilliSatoshi(-1 * deltas.Local)
}
if deltas.Remote >= 0 {
theirBalance += lnwire.MilliSatoshi(deltas.Remote)
} else {
theirBalance -= lnwire.MilliSatoshi(-1 * deltas.Remote)
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

So reverted the changes caused by this comment and now the delta remains as int64 all the way until balanceAboveReserve

We add a new update type to the payment descriptor to describe this new
type of htlc. This type of HTLC will only end up being set if explicitly
signalled by external software.
We also add the IsAdd helper to the AuxHtlcDescriptor, as external
software using the aux framework might want to know which type of HTLC
this is.
@GeorgeTsagk
Copy link
Collaborator Author

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a "noop add" HTLC type, which is a clever mechanism for updating channel state via an HTLC that effectively returns its value to the sender upon settlement.

My review has identified a critical bug related to a potential integer underflow in balance calculations, a high-severity issue with a misleading comment, and a medium-severity issue regarding an informal comment. Addressing these points will significantly improve the robustness and maintainability of this new feature.

Comment on lines +3178 to +3212
func balanceAboveReserve(party lntypes.ChannelParty, delta int64,
channel *channeldb.OpenChannel) bool {

channel.RLock()
defer channel.RUnlock()

c := channel

localReserve := lnwire.NewMSatFromSatoshis(c.LocalChanCfg.ChanReserve)
remoteReserve := lnwire.NewMSatFromSatoshis(c.RemoteChanCfg.ChanReserve)

switch {
case party.IsLocal():
totalLocal := c.LocalCommitment.LocalBalance
if delta >= 0 {
totalLocal += lnwire.MilliSatoshi(delta)
} else {
totalLocal -= lnwire.MilliSatoshi(-1 * delta)
}

return totalLocal > localReserve

case party.IsRemote():
totalRemote := c.RemoteCommitment.RemoteBalance
if delta >= 0 {
totalRemote += lnwire.MilliSatoshi(delta)
} else {
totalRemote -= lnwire.MilliSatoshi(-1 * delta)
}

return totalRemote > remoteReserve
}

return false
}

Choose a reason for hiding this comment

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

critical

There's a potential for an integer underflow here. totalLocal is a lnwire.MilliSatoshi (which is a uint64), and delta is an int64. If delta is negative and its absolute value is greater than totalLocal, the subtraction totalLocal -= lnwire.MilliSatoshi(-1 * delta) will underflow. This would cause incorrect balance calculations, potentially leading to channel failures.

To prevent this, all balance calculations involving deltas should be performed using signed integers before converting back to lnwire.MilliSatoshi. The same issue exists for the remote party's balance calculation.

	switch {
	case party.IsLocal():
		// To avoid underflows, we'll do the balance arithmetic using
		// signed integers.
		newBalance := int64(c.LocalCommitment.LocalBalance) + delta
		if newBalance < 0 {
			return false
		}

		return lnwire.MilliSatoshi(newBalance) > localReserve

	case party.IsRemote():
		// To avoid underflows, we'll do the balance arithmetic using
		// signed integers.
		newBalance := int64(c.RemoteCommitment.RemoteBalance) + delta
		if newBalance < 0 {
			return false
		}

		return lnwire.MilliSatoshi(newBalance) > remoteReserve
	}

Copy link
Collaborator Author

@GeorgeTsagk GeorgeTsagk Jul 4, 2025

Choose a reason for hiding this comment

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

I think we know that the negative delta can never be greater than the balance, as that would indicate a greater issue in the lightning channel state machine. We verify what the balance is before creating the add entry in the log update:

lnd/lnwallet/channel.go

Lines 6018 to 6030 in 4f0d187

func (lc *LightningChannel) addHTLC(htlc *lnwire.UpdateAddHTLC,
openKey *models.CircuitKey, buffer BufferType) (uint64, error) {
lc.Lock()
defer lc.Unlock()
pd := lc.htlcAddDescriptor(htlc, openKey)
if err := lc.validateAddHtlc(pd, buffer); err != nil {
return 0, err
}
lc.updateLogs.Local.appendHtlc(pd)

If that was the case we'd run into it here too:

lnd/lnwallet/channel.go

Lines 4743 to 4752 in 4f0d187

if deltas.Local >= 0 {
ourBalance += lnwire.MilliSatoshi(deltas.Local)
} else {
ourBalance -= lnwire.MilliSatoshi(-1 * deltas.Local)
}
if deltas.Remote >= 0 {
theirBalance += lnwire.MilliSatoshi(deltas.Remote)
} else {
theirBalance -= lnwire.MilliSatoshi(-1 * deltas.Remote)
}

I'm not against adding extra checks, but that would mean we'd have to change a few func signatures to return errors.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we know that the negative delta can never be greater than the balance, as that would indicate a greater issue in the lightning channel state machine. We verify what the balance is before creating the add entry in the log update:

A comment that explains the assumption here would be appropriate.
Otherwise someone coming across this in the future will have to dig through a lot of stuff to find out why this is safe.

We update the lightning channel state machine in some key areas. If the
noop TLV is set in the update_add_htlc custom records then we change the
entry type to noop. When settling the HTLC if the type is noop we credit
the satoshi amount back to the sender.
Adds some simple tests to check the noop HTLC logic of the lightning
channel.
To make sure we don't cause force-closures because of commit sig
mismatches, we add a test case to verify that the retransmitted HTLC
matches the original HTLC.
Copy link
Collaborator

@guggero guggero left a comment

Choose a reason for hiding this comment

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

Requesting more comments in the balanceAboveReserve and release notes, other than that LGTM 🎉

Copy link
Member

@yyforyongyu yyforyongyu left a comment

Choose a reason for hiding this comment

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

Will do another round to check the tests.


// NoopAddHtlcType is the (golang) type of the TLV record that's used to signal
// that an HTLC should be a noop HTLC.
type NoopAddHtlcType = tlv.TlvType65544
Copy link
Member

Choose a reason for hiding this comment

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

why are we defining the same type twice?


switch {
case party.IsLocal():
totalLocal := c.LocalCommitment.LocalBalance
Copy link
Member

Choose a reason for hiding this comment

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

the balance should come from commitChain.tip().ourBalance as c.LocalCommitment is not updated yet?

@@ -3833,7 +3924,7 @@ func (lc *LightningChannel) validateCommitmentSanity(theirLogCounter,
// Go through all updates, checking that they don't violate the
// channel constraints.
for _, entry := range updates {
if entry.EntryType == Add {
if entry.isAdd() {
Copy link
Member

Choose a reason for hiding this comment

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

We should also update recordSettlement at L4762

// balanceAboveReserve checks if the balance for the provided party is above the
// configured reserve. It also uses the balance delta for the party, to account
// for entry amounts that have been processed already.
func balanceAboveReserve(party lntypes.ChannelParty, delta int64,
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure I follow this check - since it's only called when we are processing the Settle, and before that there must be an Add. To lock in that Add, we must have already checked that the channel balance wouldn't drop below the reserve, plus a few other checks such as the fee buffer. So this method should always return true? Unless we don't check for reserves when processing tap HTLCs?

channel := lc.channelState
delta := balanceDeltas.GetForParty(party)

// If the receiver has existing balance above reserve then we go ahead
Copy link
Member

Choose a reason for hiding this comment

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

The dual Local/Remote of a commitment has nothing to do with Sender/Receiver of an HTLC here - either we are receiving or sending HTLCs, there are always two rounds of commit_sig and revoke_and_ack being exchanged.

chanType channeldb.ChannelType) updateType {

noopTLV := uint64(NoOpHtlcType.TypeVal())
if _, ok := records[noopTLV]; ok && chanType.HasTapscriptRoot() {
Copy link
Member

Choose a reason for hiding this comment

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

We should at least log an error here when there is such a tlv record, yet the channel isn't aux chan.

@@ -2993,6 +3007,19 @@ func (lc *LightningChannel) evaluateHTLCView(view *HtlcView,
)
if rmvHeight == 0 {
switch {
// If this a noop add, then when we settle the
// HTLC, we actually credit the sender with the
// amount again, thus making it a noop. Noop
Copy link
Member

Choose a reason for hiding this comment

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

I don't think it's accurate as we conditionally cancel the amount.

channel *channeldb.OpenChannel) bool {

channel.RLock()
defer channel.RUnlock()
Copy link
Member

Choose a reason for hiding this comment

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

This usually means a method should exist on the struct itself.


// NoOpAdd is an update type that adds a new HTLC entry into the log.
// This differs from the normal Add type, in that when settled the
// balance will go back to the sender, rather than be credited for the
Copy link
Member

Choose a reason for hiding this comment

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

We should make it clear that this balance goes back conditionally based on the reserve.

@@ -58,6 +64,8 @@ func (u updateType) String() string {
return "Settle"
case FeeUpdate:
return "FeeUpdate"
case NoOpAdd:
return "NoopAdd"
Copy link
Member

Choose a reason for hiding this comment

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

nit: NoOpAdd

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: In progress
Development

Successfully merging this pull request may close these issues.

8 participants