Skip to content
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

lnwallet: add new NoopAdd payDesc entry type #9430

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions lnwallet/aux_signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ import (
"github.com/lightningnetwork/lnd/tlv"
)

// htlcCustomSigType is the TLV type that is used to encode the custom HTLC
// signatures within the custom data for an existing HTLC.
var htlcCustomSigType tlv.TlvType65543
var (
// htlcCustomSigType is the TLV type that is used to encode the custom
// HTLC signatures within the custom data for an existing HTLC.
htlcCustomSigType tlv.TlvType65543

// noopHtlcType is the TLV that that's used in the update_add_htlc
// message to indicate the presence of a noop HTLC. This has no encoded
// value, but is used to indicate that the HTLC is a noop.
noopHtlcType tlv.TlvType65544
)

// AuxHtlcDescriptor is a struct that contains the information needed to sign or
// verify an HTLC for custom channels.
Expand Down
110 changes: 93 additions & 17 deletions lnwallet/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,20 @@
remoteOutputIndex = htlc.OutputIndex
}

customRecords := htlc.CustomRecords.Copy()

entryType := Add

noopTLV := uint64(noopHtlcType.TypeVal())
if _, ok := customRecords[noopTLV]; ok {
entryType = NoopAdd
}

// The NoopAdd HTLC is an internal construct, and isn't meant to show up
// on the wire. So we'll remove the special element from the set of
// custom records.
delete(customRecords, noopTLV)
Copy link
Collaborator

Choose a reason for hiding this comment

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

IIUC this wouldn't really matter, as the receiver of the wire message would just ignore this unknown type, are we just saving wire space here?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ended up doing this as we have some tests that assert what an HTLC should look like for transmission. If we continue to thread these over, then those start to fail.

On the other hand, maybe it makes sense to expose this, as then the receiver just accepts the noop if the sender does, vs the current logic of assuming that it's always noop when the tapscript bit is set.


// With the scripts reconstructed (depending on if this is our commit
// vs theirs or a pending commit for the remote party), we can now
// re-create the original payment descriptor.
Expand All @@ -560,7 +574,7 @@
RHash: htlc.RHash,
Timeout: htlc.RefundTimeout,
Amount: htlc.Amt,
EntryType: Add,
EntryType: entryType,
HtlcIndex: htlc.HtlcIndex,
LogIndex: htlc.LogIndex,
OnionBlob: htlc.OnionBlob,
Expand All @@ -571,7 +585,7 @@
theirPkScript: theirP2WSH,
theirWitnessScript: theirWitnessScript,
BlindingPoint: htlc.BlindingPoint,
CustomRecords: htlc.CustomRecords.Copy(),
CustomRecords: customRecords,
}, nil
}

Expand Down Expand Up @@ -1101,6 +1115,11 @@
},
}

noopTLV := uint64(noopHtlcType.TypeVal())
if _, ok := pd.CustomRecords[noopTLV]; ok {
pd.EntryType = NoopAdd
}

isDustRemote := HtlcIsDust(
lc.channelState.ChanType, false, lntypes.Remote,
feeRate, wireMsg.Amount.ToSatoshis(), remoteDustLimit,
Expand Down Expand Up @@ -1336,6 +1355,11 @@
Local: commitHeight,
},
}
noopTLV := uint64(noopHtlcType.TypeVal())

if _, ok := pd.CustomRecords[noopTLV]; ok {
pd.EntryType = NoopAdd
}

// We don't need to generate an htlc script yet. This will be
// done once we sign our remote commitment.
Expand Down Expand Up @@ -1737,7 +1761,7 @@
// but this Add restoration was a no-op as every single one of
// these Adds was already restored since they're all incoming
// htlcs on the local commitment.
if payDesc.EntryType == Add {
if payDesc.isAdd() {
continue
}

Expand Down Expand Up @@ -1882,7 +1906,7 @@
}

switch payDesc.EntryType {
case Add:
case Add, NoopAdd:
// The HtlcIndex of the added HTLC _must_ be equal to
// the log's htlcCounter at this point. If it is not we
// panic to catch this.
Expand Down Expand Up @@ -2974,6 +2998,20 @@
)
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.
case entry.EntryType == Settle &&
addEntry.EntryType == NoopAdd:

delta := int64(entry.Amount)
balanceDeltas.ModifyForParty(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Also important to mention that parties could disagree here, with receiver potentially trying out malicious things (like going to chain, like @ZZiigguurraatt mentioned), and try to "grab" both the custom data and the btc amount

That's a good reason why I believe we should not convert the entry type to NoopAdd for HTLCs that carry a btc amount above a certain threshold, or let the user explicitly mark it somehow as noop

Copy link
Member Author

Choose a reason for hiding this comment

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

Not sure I follow, but if they disagree, a force close occurs. Which doesn't benefit either party, as they need to pay for chain fees, and now the receiver will likely end up with nothing as it'll cost more to spend the output than it's actually worth.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I mean that we should not allow significant btc amounts being carried by HTLCs of type NoopAdd

That breaks the incentive alignment and someone could actually want to misbehave and force-close/pay fees

party.CounterParty(),
func(acc int64) int64 {
return acc + delta
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍 So by default the custom aux blob is being updated / preserved in the background, so the only diff required is the one that pays back the sats, didn't expect this to be that lean 💯

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep, was great how small it turned out to be in the end. This area of the code has been refactored a bit also, which served to simplify the addition of this new feature.

},
)

// If an incoming HTLC is being settled, then
// this means that the preimage has been
// received by the settling party Therefore, we
Expand Down Expand Up @@ -3011,7 +3049,7 @@
liveAdds := fn.Filter(
view.Updates.GetForParty(party),
func(pd *paymentDescriptor) bool {
isAdd := pd.EntryType == Add
isAdd := pd.isAdd()
shouldSkip := skip.GetForParty(party).
Contains(pd.HtlcIndex)

Expand Down Expand Up @@ -3050,7 +3088,7 @@
// corresponding to whoseCommitmentChain.
isUncommitted := func(update *paymentDescriptor) bool {
switch update.EntryType {
case Add:
case Add, NoopAdd:
return update.addCommitHeights.GetForParty(
whoseCommitChain,
) == 0
Expand Down Expand Up @@ -3814,7 +3852,7 @@
// Go through all updates, checking that they don't violate the
// channel constraints.
for _, entry := range updates {
if entry.EntryType == Add {
if entry.isAdd() {
// An HTLC is being added, this will add to the
// number and amount in flight.
amtInFlight += entry.Amount
Expand Down Expand Up @@ -4470,6 +4508,13 @@
// Next, we'll need to send over any updates we sent as part of
// this new proposed commitment state.
for _, logUpdate := range commitDiff.LogUpdates {
if htlc, ok := logUpdate.UpdateMsg.(*lnwire.UpdateAddHTLC); ok {

Check failure on line 4511 in lnwallet/channel.go

View workflow job for this annotation

GitHub Actions / lint code

the line is 88 characters long, which exceeds the maximum of 80 characters. (ll)
delete(htlc.CustomRecords, uint64(noopHtlcType.TypeVal()))

Check failure on line 4512 in lnwallet/channel.go

View workflow job for this annotation

GitHub Actions / lint code

the line is 90 characters long, which exceeds the maximum of 80 characters. (ll)

if len(htlc.CustomRecords) == 0 {
htlc.CustomRecords = nil
}
}
commitUpdates = append(
commitUpdates, logUpdate.UpdateMsg,
)
Expand Down Expand Up @@ -4501,8 +4546,8 @@
// updates comes after the LogUpdates+CommitSig.
//
// ---logupdates--->
// ---commitsig---->
// ---revocation--->
// ---commitsig---->
updates = append(commitUpdates, updates...)
} else {
// Otherwise, the revocation should come before LogUpdates
Expand Down Expand Up @@ -5693,7 +5738,7 @@
// don't re-forward any already processed HTLC's after a
// restart.
switch {
case pd.EntryType == Add && committedAdd && shouldFwdAdd:
case pd.isAdd() && committedAdd && shouldFwdAdd:
// Construct a reference specifying the location that
// this forwarded Add will be written in the forwarding
// package constructed at this remote height.
Expand All @@ -5712,7 +5757,7 @@
addUpdatesToForward, pd.toLogUpdate(),
)

case pd.EntryType != Add && committedRmv && shouldFwdRmv:
case !pd.isAdd() && committedRmv && shouldFwdRmv:
// Construct a reference specifying the location that
// this forwarded Settle/Fail will be written in the
// forwarding package constructed at this remote height.
Expand Down Expand Up @@ -5951,7 +5996,7 @@
// Grab all of our HTLCs and evaluate against the dust limit.
for e := lc.updateLogs.Local.Front(); e != nil; e = e.Next() {
pd := e.Value
if pd.EntryType != Add {
if !pd.isAdd() {
continue
}

Expand All @@ -5970,7 +6015,7 @@
// Grab all of their HTLCs and evaluate against the dust limit.
for e := lc.updateLogs.Remote.Front(); e != nil; e = e.Next() {
pd := e.Value
if pd.EntryType != Add {
if !pd.isAdd() {
continue
}

Expand Down Expand Up @@ -6043,9 +6088,25 @@
func (lc *LightningChannel) htlcAddDescriptor(htlc *lnwire.UpdateAddHTLC,
openKey *models.CircuitKey) *paymentDescriptor {

// TODO(roasbeef): can use push amt to simplify logic, not have to
Copy link
Collaborator

Choose a reason for hiding this comment

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

wdym in this todo: always push some sats amount on channel open, to make sure remote side can always anchor? if that's the case
a) this todo shouldn't be here, as lnd doesn't know anything about custom channel funding details
b) for custom channels with large btc capacity, it may be uneconomical to push enough sats to the other side to get them over the reserve

Copy link
Member Author

Choose a reason for hiding this comment

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

it may be uneconomical to push enough sats to the other side to get them over the reserve

Would need to double check, but I think the starting value of a push_amt is actually exempt from the reserve constraints.

wdym in this todo: always push some sats amount on channel open, to make sure remote side can always anchor? if that's the case

Yes, so we'd have the APIs set up to always push an amount over to ensure they can anchor for as starting state.

Otherwise, we'll need to make sure that we only ever push for the "first" HTLC, which can be a bit tricky if you have many pending HTLCs in a new commitment transaction.

// detect if remote party has enough funds for anchor
entryType := Add

customRecords := htlc.CustomRecords.Copy()
if lc.channelState.ChanType.HasTapscriptRoot() {
entryType = NoopAdd
Copy link
Collaborator

Choose a reason for hiding this comment

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

this is a nice way to bootstrap things, but I think HasTapscriptRoot() is insufficient as the sole criteria here

We could be sending raw btc HTLCs over the channel, or we could be sending HTLCs carrying custom data and also carry a significant amount of btc. In both of the previous cases we definitely don't want to give the amount back to sender upon settlement

From the top of my head: we could also check for this HTLC's custom records, plus the actual amount of the btc delta and then decide whether we're in a NoopAdd case

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed. I think instead we may want to add a new aux interface, or re-use an existing one, to know when we should be using this or not.


// We'll add an internal custom record here to make sure that
// across restarts, we recognize this as a noop HTLC.
if customRecords == nil {
customRecords = make(lnwire.CustomRecords)
}
customRecords[uint64(noopHtlcType.TypeVal())] = nil
}

return &paymentDescriptor{
ChanID: htlc.ChanID,
EntryType: Add,
EntryType: entryType,
RHash: PaymentHash(htlc.PaymentHash),
Timeout: htlc.Expiry,
Amount: htlc.Amount,
Expand All @@ -6054,7 +6115,7 @@
OnionBlob: htlc.OnionBlob,
OpenCircuitKey: openKey,
BlindingPoint: htlc.BlindingPoint,
CustomRecords: htlc.CustomRecords.Copy(),
CustomRecords: customRecords,
}
}

Expand Down Expand Up @@ -6107,17 +6168,32 @@
lc.updateLogs.Remote.htlcCounter)
}

entryType := Add

customRecords := htlc.CustomRecords.Copy()
if lc.channelState.ChanType.HasTapscriptRoot() {
if customRecords == nil {
customRecords = make(lnwire.CustomRecords)
}

// We'll add an internal custom record here to make sure that
// across restarts, we recognize this as a noop HTLC.
customRecords[uint64(noopHtlcType.TypeVal())] = nil

entryType = NoopAdd
}

pd := &paymentDescriptor{
ChanID: htlc.ChanID,
EntryType: Add,
EntryType: entryType,
RHash: PaymentHash(htlc.PaymentHash),
Timeout: htlc.Expiry,
Amount: htlc.Amount,
LogIndex: lc.updateLogs.Remote.logIndex,
HtlcIndex: lc.updateLogs.Remote.htlcCounter,
OnionBlob: htlc.OnionBlob,
BlindingPoint: htlc.BlindingPoint,
CustomRecords: htlc.CustomRecords.Copy(),
CustomRecords: customRecords,
}

localACKedIndex := lc.commitChains.Remote.tail().messageIndices.Local
Expand Down Expand Up @@ -9765,7 +9841,7 @@

// We don't save add updates as they are restored from the
// remote commitment in restoreStateLogs.
if pd.EntryType == Add {
if pd.isAdd() {
continue
}

Expand Down
54 changes: 53 additions & 1 deletion lnwallet/channel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1383,8 +1383,8 @@ func TestForceCloseDustOutput(t *testing.T) {
bobChannel.channelState.RemoteChanCfg.ChanReserve = 0

htlcAmount := lnwire.NewMSatFromSatoshis(500)

aliceAmount := aliceChannel.channelState.LocalCommitment.LocalBalance

bobAmount := bobChannel.channelState.LocalCommitment.LocalBalance

// Have Bobs' to-self output be below her dust limit and check
Expand Down Expand Up @@ -11272,3 +11272,55 @@ func TestCreateCooperativeCloseTx(t *testing.T) {
})
}
}

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

// Create a test channel which will be used for the duration of this
// unittest. The channel will be funded evenly with Alice having 5 BTC,
// and Bob having 5 BTC.
chanType := channeldb.SimpleTaprootFeatureBit |
channeldb.AnchorOutputsBit | channeldb.ZeroHtlcTxFeeBit |
channeldb.SingleFunderTweaklessBit | channeldb.TapscriptRootBit
aliceChannel, bobChannel, err := CreateTestChannels(
t, chanType,
)
require.NoError(t, err, "unable to create test channels")

const htlcAmt = 10_000
htlc, preimage := createHTLC(0, htlcAmt)

aliceBalance := aliceChannel.channelState.LocalCommitment.LocalBalance
bobBalance := bobChannel.channelState.LocalCommitment.LocalBalance

// Have Alice add the HTLC, then lock it in with a new state transition.
aliceHtlcIndex, err := aliceChannel.AddHTLC(htlc, nil)
require.NoError(t, err, "alice unable to add htlc")
bobHtlcIndex, err := bobChannel.ReceiveHTLC(htlc)
require.NoError(t, err, "bob unable to receive htlc")
if err := ForceStateTransition(aliceChannel, bobChannel); err != nil {
t.Fatalf("Can't update the channel state: %v", err)
}

// We'll have Bob settle the HTLC, then force another state transition.
err = bobChannel.SettleHTLC(preimage, bobHtlcIndex, nil, nil, nil)
require.NoError(t, err, "bob unable to settle inbound htlc")
err = aliceChannel.ReceiveHTLCSettle(preimage, aliceHtlcIndex)
if err != nil {
t.Fatalf("alice unable to accept settle of outbound "+
"htlc: %v", err)
}
if err := ForceStateTransition(aliceChannel, bobChannel); err != nil {
t.Fatalf("Can't update the channel state: %v", err)
}

aliceBalanceFinal := aliceChannel.channelState.LocalCommitment.LocalBalance //nolint:ll
bobBalanceFinal := bobChannel.channelState.LocalCommitment.LocalBalance

// The balances of Alice and Bob should be the exact same and shouldn't
// have changed.
require.Equal(t, aliceBalance, aliceBalanceFinal)
require.Equal(t, bobBalance, bobBalanceFinal)
}
Loading
Loading