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 7 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

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.

@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.

@@ -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
@GeorgeTsagk GeorgeTsagk requested a review from Roasbeef June 25, 2025 12:16
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.

@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

@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 3231
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.

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?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

the previous one is a var
var NoOpHtlcType tlv.TlvType65544

this one is a golang type
type NoopAddHtlcType = tlv.TlvType65544

I think I can do better with naming, I can rename the first one to NoOpHtlcTLVEntry and second one to NoOpHtlcTLVType

// 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?

Copy link
Collaborator Author

@GeorgeTsagk GeorgeTsagk Jul 23, 2025

Choose a reason for hiding this comment

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

To lock in that Add, we must have already checked that the channel balance wouldn't drop below the reserve,

This would be for the remote side. If Alice is sending to Bob, here we want to check if Bob is below reserve. Currently if Bob is at 0% (no reserve) we can still send him 1sat even if that doesn't take him above the configured reserve. The noop-add will actually nullify the send only if Bob is sitting above reserve.

This requirement is related to how tap assets work, as we need to make sure we have a sufficient amount in order to anchor off-chain metadata that needs to be present on-chain.

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.

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 moved it here after addressing a recommendation in a previous comment
#9871 (comment)

Happy to change it again

@lightninglabs-deploy
Copy link

@Roasbeef: review reminder
@yyforyongyu: review reminder
@GeorgeTsagk, remember to re-request review from reviewers when ready

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.

I think this new type adds lots of complexity to the existing flow, and the expectation from the usage of Noop adds must be explicitly documented (maybe in tapd). I think the purpose of noop can be defeated via multiple ways,

  • if the remote sets a large channel reserve, sats will always be claimed by the receiver.
  • if the remote receives lots of payments and refuses to settle, they can claim them via FC - this can be mitigated via restricting the num of inflight payments.
  • if the custom channel is used to send normal HTLCs, the receiver is in danger as the payments can be claimed back.

I guess these are handled in the tapd side?

totalLocal -= lnwire.MilliSatoshi(-1 * delta)
}

return totalLocal > localReserve
Copy link
Member

Choose a reason for hiding this comment

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

Think this needs to be >=

Copy link
Collaborator Author

@GeorgeTsagk GeorgeTsagk Aug 7, 2025

Choose a reason for hiding this comment

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

I think it doesn't really matter if we're greater vs greater-equal than the value here, worst case we need to send an extra 1msat to pass the check

What really matters here is that both peers perform the same check with the same values, as there absolutely needs to be an agreement here on whether the check is satisfied (otherwise it would lead to balance disagreements -> conflicting commitments -> force closure)

Added some context around this in the code too

}

if noopFlag && !chanType.HasTapscriptRoot() {
lc.log.Warnf("Received flag for noop-add over a channel that " +
Copy link
Member

Choose a reason for hiding this comment

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

Let's make this an error since it's dangerous for the receiver? IIUC in tap chan we can also send normal HTLCs, I'm wondering if the receiver can refuse receiving noop Adds, say via a config. Or is there a way to check that if it's sending normal htlcs we disable it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

One important thing to keep in mind here is that all of the code introduced in this PR is not used unless external software (i.e tapd in our case) explicitly tells LND to treat this as a noop HTLC (via the noop TLV). The control guards around whether the TLV is set lies externally.

Specifically we only set it if the intent of the tap payment is clearly to send assets and not sats. If a normal (sats) payment is initiated we don't set the flag.

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 wondering if the receiver can refuse receiving noop Adds, say via a config

If we sign as if we're doing it, but they don't, then that leads to a force close just like if any of the other channel rendering attributes are ignored.

Or is there a way to check that if it's sending normal htlcs we disable it?

When this rolls out, there'll be a new feature bit used to signal if both sides understand the feature.

// also verify that the noop-adds will start the nullification only once
// Bob is above reserve.
reserveTarget := (numHtlc / 2) * htlcAmt
bobReserve := bobBalance + reserveTarget
Copy link
Member

Choose a reason for hiding this comment

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

Does it mean an attacker can just increase their chan reserve and receive sats that are supposedly not credited to them in the noop Add context, hence the sender may falsely think they are getting the sats back?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Interesting point, but again given that this is only used in the context of custom channels, and specifically since the TLV flag will only be set for HTLCs that carry dust amt plus metadata the exposure is not very large.

I guess we could add some guard during the custom channel funding against the remote setting a very large reserve in order to totally eliminate this threat.

Copy link
Member

Choose a reason for hiding this comment

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

To add to this, the reserve of a channel is set by the opposite party. So a peer can't unilaterally pick a very large reserve. It's also the case that this is beneficial to both parties, since it enables sending without continually pushing another right-above-dust value to the opposite party.

As we're the ones that accept the reserve proposals, we'll reject it if we consider the reserve too high, as that impacts the usability of the channel.

@GeorgeTsagk
Copy link
Collaborator Author

GeorgeTsagk commented Aug 7, 2025

I think this new type adds lots of complexity to the existing flow, and the expectation from the usage of Noop adds must be explicitly documented (maybe in tapd). I think the purpose of noop can be defeated via multiple ways,

  • if the remote sets a large channel reserve, sats will always be claimed by the receiver.
  • if the remote receives lots of payments and refuses to settle, they can claim them via FC - this can be mitigated via restricting the num of inflight payments.
  • if the custom channel is used to send normal HTLCs, the receiver is in danger as the payments can be claimed back.

I guess these are handled in the tapd side?

Thanks for the great points @yyforyongyu

I'd like to reply to the last 2 bullets:

The fact that the HTLCs still actually carry the amount while in-flight is also sort of a feature. We want to be able to materialize the HTLCs on chain if that needs to happen, as the metadata that are present in the outpoint must be preserved (that's where the state of the custom channels resides). Below dust amounts would contribute towards fees and the HTLCs would get lost into the void.

if the custom channel is used to send normal HTLCs

Yes, the custom channel can be used to send normal HTLCs but it would never do so and also set the noop TLV flag for it. That's a strong responsibility of the external software -- to make sane decisions around when an HTLC should be treated as a noop.

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.
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.
@GeorgeTsagk
Copy link
Collaborator Author

Rebased on latest master

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.

LGTM⛵️

For future references, this PR adds a new feature via introducing a new htlc type, NoOp, which allows carrying info via HTLCs without extra cost in sats. The sender pays sats as normal when sending HTLCs, and the receiver will return the sats back via settlements. To safely use this feature, the custom channels must make sure that,

  • limit the remote's channel reserve to a sane value, to avoid NoOp not taking effect.
  • the receiver must be careful when receiving normal sats in custom channels, as it can create fake settlement of invoices.

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.

Looking really good!

I think only more thing is missing on this PR. We should add an explicit check that if the remote party sends an HTLC w/ this special TLV active, and we aren't using the overlay channel type, we reject it. This is an extra defensive check to further firewall this feature to ensure it only applies to this special channel type.

// don't need to record the amount as it
// was never sent over to the other
// side.
if u.noOpSettle {
Copy link
Member

Choose a reason for hiding this comment

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

👍

}

if noopFlag && !chanType.HasTapscriptRoot() {
lc.log.Warnf("Received flag for noop-add over a channel that " +
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 return an error here instead.

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 can't really see why this is needed:

  • Assuming that both parties run LND and the same checks then both will result in treating this as a normal HTLC. This code line here is purely informational, it's not that we simply log if the remote party tried "cheating". The noop flag only signals the intention for an HTLC to be treated as noop, but the checks may render it a normal HTLC -- and that is the above case, an HTLC that set the noop flag ended up being reverted to a normal HTLC and we just let the user know. If on the other hand the two parties run different software and disagree on whether an HTLC should be a noop, then they'd force close, and that's also something we'll try to avoid.

  • Returning an error here means we allow for a couple of call sites to fail for a not really crucial reason. It is possible that the intention to create a noop is signaled, but the criteria are just not met. I'm not confident that we should error out all these call sites for something that is predicted behavior: diskHtlcToPayDesc, logUpdateToPayDesc, remoteLogUpdateToPayDesc, htlcAddDescriptor, ReceiveHTLC

@Roasbeef
Copy link
Member

if the remote receives lots of payments and refuses to settle, they can claim them via FC - this can be mitigated via restricting the num of inflight payments.

Correct that since we only apply the noop on settle, each pending HTLC still needs that above-dust-amount to anchor it to the commitment transaction. If they go to chain, then the receiver can pull the committed amount, and also that above-dust-amount.

The only direct way to get rid of this final limitation would be to switch to a zero-value output. However, those are disallowed by default policy. May we need the degens to abuse this feature so it becomes standard....

if the custom channel is used to send normal HTLCs, the receiver is in danger as the payments can be claimed back.

So normal HTLCs wouldn't be marked as noop. Only these special HTLCs would be, currently we drive that type based on the set of custom records we see. Another approach here would be to add another aux interface to allow that check to be dynamic.

Left a comment to error out instead of warn to make this case explicit.

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