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

channeldb+invoices: add spontaneous AMP receiving + sending via SendToRoute #5108

Merged
merged 22 commits into from
Apr 7, 2021

Conversation

cfromknecht
Copy link
Contributor

@cfromknecht cfromknecht commented Mar 15, 2021

This PR adds the ability to receive spontaneous AMP payments, and send them by crafting the appropriate request using SendToRoute. This PR is opened as a Draft since not all feature bits/TLV record types are decided.

PLEASE DO NOT RUN THIS ON A PRODUCTION NODE

Summary of changes:

  • Adds support for parsing AMP record from TLV onion payload
  • Adds (non-final) AMP feature bits
  • Add InvoiceRef support for querying by pay_addr only
  • Add JIT AMP-invoice insertion when receiving AMP HTLC
  • Validation + testing around HTLC-level preimage persistence
  • Reconstruction of HTLCSet child preimage/hashes using amp package
  • Integration test demonstrating multi-path AMP via SendToRoute

TODO:

  • expand itest assertions
  • add more unit testing around AMP invoice settlement w/ invalid HTLC sets
  • fix invoice parsing for AMP 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.

Completed an initial high level pass......my body is ready 😈

record/amp.go Show resolved Hide resolved
htlcswitch/hop/payload.go Show resolved Hide resolved
lnwire/features.go Outdated Show resolved Hide resolved
invoices/update.go Show resolved Hide resolved
channeldb/invoices.go Show resolved Hide resolved
invoices/update.go Show resolved Hide resolved
invoices/update.go Outdated Show resolved Hide resolved
invoices/update.go Outdated Show resolved Hide resolved
invoices/update.go Outdated Show resolved Hide resolved
"github.com/stretchr/testify/require"
)

func testSendToRouteAMP(net *lntest.NetworkHarness, t *harnessTest) {
Copy link
Member

Choose a reason for hiding this comment

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

IT LIVES!!!! 🤘

@@ -266,6 +266,10 @@ var allTestCases = []*testCase{
name: "sendtoroute multi path payment",
test: testSendToRouteMultiPath,
},
{
name: "sendtoroute amp",
Copy link
Contributor

Choose a reason for hiding this comment

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

Yooohooo!

@@ -206,15 +229,91 @@ func updateMpp(ctx *invoiceUpdateCtx,
return &update, ctx.acceptRes(resultAccepted), nil
}

var (
Copy link
Member

Choose a reason for hiding this comment

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

If we add a check for an invalid AMP payload, before the the check for the total amount, then it would be possible to send a corrupt share to cancel all HTLCs w/ that set ID. This lets the sender bail out if they know things won't work for w/e reason. It would also be useful for more iterative probing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it isn't really possible to send an invalid AMP payload, and it's only possible to know if the shares are invalid after receiving all of them. if the receiver tries to reconstruct early they will always be invalid. given that the amount is the termination condition, i don't think there's any getting around that.

the current iteration does fail back all HTLCs if the reconstruction fails tho, to support the probing use case.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, yeah I get what you mean. Perhaps this can be done by extending the ValidateParsedPayloadTypes checks: if the sender sends a payload w/o an amount, but with a valid set ID of already held HTLCs, they call get cancelled back?

given that the amount is the termination condition, i don't think there's any getting around that.

Another idea: we reserve a special value of the childIndex that can be used to signal intent to bail the payment early.

The motivation here is to support a fast-fail mode w/o having to wait for the normal MPP timeout. This can be useful in contexts like attempting to Loop In wherein the user doesn't really know how much they can Loop In, and even if we fail w/ probing the full amount, if can get some additional feedback, then we can offer a: "Loop In as much as you can" mode.

Stepping back a bit, perhaps this can just be implemented by moving one layer higher: a special custom record that just says "ok, can you guys cancel things back now". Applications would be able to use the to-be-designed API for handling hodl AMP payments to handle this, or we'd grow to do it automatically.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the PR to include immediate cancellation when seed reconstruction fails as well as some tests.

Stepping back a bit, perhaps this can just be implemented by moving one layer higher: a special custom record that just says "ok, can you guys cancel things back now". Applications would be able to use the to-be-designed API for handling hodl AMP payments to handle this, or we'd grow to do it automatically.

This idea sounds pretty workable! Seems like a pretty contained change as well, also wouldn't need reserved values, special cases, etc in the base protocol

Copy link
Contributor

Choose a reason for hiding this comment

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

So the receiver gets the cancel signal and cancels all htlcs back, but does the sender learn from that which htlcs were actually held by the receiver vs the ones that were still in-flight or didn't even reach the receiver? I suppose the sender can inspect the failure message to see what the origin of the failure is. Only when an htlc arrives at the receiver after the set has already been cancelled, and is then cancelled back, what does this look like to the sender? Will it be clear that that final htlc wasn't part of the set and reached the receiver "independently", possibly reusing liquidity that was just released?

@cfromknecht cfromknecht force-pushed the sendtoroute-amp branch 2 times, most recently from 1deef44 to e2c684e Compare March 22, 2021 19:46
@Roasbeef Roasbeef added the amp label Mar 23, 2021
@Roasbeef Roasbeef added this to the 0.13.0 milestone Mar 23, 2021
@halseth halseth self-requested a review March 24, 2021 19:32
@cfromknecht cfromknecht force-pushed the sendtoroute-amp branch 2 times, most recently from c27b375 to 0dd4856 Compare March 25, 2021 02:53
@cfromknecht cfromknecht requested a review from Roasbeef March 25, 2021 02:54
@cfromknecht cfromknecht marked this pull request as ready for review March 25, 2021 03:10
lntest/itest/lnd_amp_test.go Show resolved Hide resolved
// processAMP just-in-time inserts an invoice if this htlc is a keysend
// htlc.
func (i *InvoiceRegistry) processAMP(ctx invoiceUpdateCtx) error {
// Only allow keysend for non-mpp payments.
Copy link
Contributor

Choose a reason for hiding this comment

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

comment is wrong?

Copy link
Member

Choose a reason for hiding this comment

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

Yeah the comment is dated. IIUC, this now allows a sender to send an invalid payload that causes a failure to occur for the HTLC with the invalid payload. Still getting through the commits to check if this'll end up cancelling back the entire HTLC set w/ the same setID or not....

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is just a sanity check since we can't identify an HTLC set without (pay_addr, set_id), this does not cancel back the entire set

payAddr := ctx.mpp.PaymentAddr()

// Create placeholder invoice.
invoice := &channeldb.Invoice{
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe it is nice to keep around AMP payments that were attempted but didn't complete?

},
}

if i.cfg.KeysendHoldTime != 0 {
Copy link
Contributor

Choose a reason for hiding this comment

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

comment

Copy link
Member

Choose a reason for hiding this comment

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

IIUC, as is, if this flag is set then we won't immeidatley settle things after all teh shards arrive. However this is only useful if the caller is able to manually settle the payment using the invoice RPC APIs. The existing settle invoice API is based on the caller knowing the pre-image (sub-swaps or they made it themselves) while in this case, such an action isn't possible as the code will attempt to look up the invoice via it's payment hash as a ref: https://github.com/lightningnetwork/lnd/blob/master/invoices/invoiceregistry.go#L1030

So I think we can remove this clause all together since it can't properly be used w/ the existing API. This was originally added for hodl keysend, but in that case there's a single pre-image that's returned via the subscription API once the invoice gets "just in time" inserted into the database.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

sounds good, we can add it back one we have the ability to settle cancel the set via RPC

channeldb/invoices.go Show resolved Hide resolved
channeldb/invoices.go Outdated Show resolved Hide resolved
channeldb/invoices.go Show resolved Hide resolved
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.

Latest set of changes are looking really good! I have a final pass planned to focus on the added uni test coverage.

lnwire/features.go Show resolved Hide resolved
},
}

if i.cfg.KeysendHoldTime != 0 {
Copy link
Member

Choose a reason for hiding this comment

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

IIUC, as is, if this flag is set then we won't immeidatley settle things after all teh shards arrive. However this is only useful if the caller is able to manually settle the payment using the invoice RPC APIs. The existing settle invoice API is based on the caller knowing the pre-image (sub-swaps or they made it themselves) while in this case, such an action isn't possible as the code will attempt to look up the invoice via it's payment hash as a ref: https://github.com/lightningnetwork/lnd/blob/master/invoices/invoiceregistry.go#L1030

So I think we can remove this clause all together since it can't properly be used w/ the existing API. This was originally added for hodl keysend, but in that case there's a single pre-image that's returned via the subscription API once the invoice gets "just in time" inserted into the database.

invoices/invoiceregistry_test.go Show resolved Hide resolved
// processAMP just-in-time inserts an invoice if this htlc is a keysend
// htlc.
func (i *InvoiceRegistry) processAMP(ctx invoiceUpdateCtx) error {
// Only allow keysend for non-mpp payments.
Copy link
Member

Choose a reason for hiding this comment

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

Yeah the comment is dated. IIUC, this now allows a sender to send an invalid payload that causes a failure to occur for the HTLC with the invalid payload. Still getting through the commits to check if this'll end up cancelling back the entire HTLC set w/ the same setID or not....

invoices/invoiceregistry.go Outdated Show resolved Hide resolved
Copy link
Contributor

@halseth halseth left a comment

Choose a reason for hiding this comment

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

LGTM! I've also been building on top of this branch, and it's been working great 👍

rawFeatures := lnwire.NewRawFeatureVector(
lnwire.TLVOnionPayloadOptional,
lnwire.PaymentAddrOptional,
lnwire.AMPRequired,
Copy link
Contributor

Choose a reason for hiding this comment

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

If AMP is required, can't we also make paymentaddr and TLV required? (not that it really matters)

func (s FailResolutionResult) IsSetFailure() bool {
switch s {
case
ResultAmpReconstruction,
Copy link
Contributor

Choose a reason for hiding this comment

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

return ResultAmp.. || ResultHtlc.. || ...?

payAddr := ctx.mpp.PaymentAddr()

// Create placeholder invoice.
invoice := &channeldb.Invoice{
Copy link
Contributor

Choose a reason for hiding this comment

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

To what extent is this an attack vector? With keysend there always a single shard, so every invoice created will at least have a corresponding settled htlc with real money attached to it. With AMP, someone can endlessly send htlcs with different payment addresses. All of them are cancelled back but leave behind invoices in the database.

@@ -206,15 +229,91 @@ func updateMpp(ctx *invoiceUpdateCtx,
return &update, ctx.acceptRes(resultAccepted), nil
}

var (
Copy link
Contributor

Choose a reason for hiding this comment

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

So the receiver gets the cancel signal and cancels all htlcs back, but does the sender learn from that which htlcs were actually held by the receiver vs the ones that were still in-flight or didn't even reach the receiver? I suppose the sender can inspect the failure message to see what the origin of the failure is. Only when an htlc arrives at the receiver after the set has already been cancelled, and is then cancelled back, what does this look like to the sender? Will it be clear that that final htlc wasn't part of the set and reached the receiver "independently", possibly reusing liquidity that was just released?


// Using the child descriptors, reconstruct the root seed and derive the
// child hash/preimage pairs for each of the HTLCs.
children := amp.ReconstructChildren(childDescs...)
Copy link
Contributor

Choose a reason for hiding this comment

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

I had one thing come to my mind about constructing the shares. Maybe it is trivial because I am not completely understandig the tree concept yet.

Suppose the sender sends three htlcs. The share tree then looks like this:

   R
   /\
  /  \
 A  / \ 
   /   \
  B     C

C arrives, but A and B fail. Then the sender decides to replace A and B by a single htlc. They didn't do this in the first place, because the amount for that single htlc forces them to take an expensive route. How is the share for this new htlc constructed? I guess it is just A^B?

If the single replacement htlc fails then too and another thing needs to be tried, is the previous tree 'restored'?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes the new share is constructed as A^B, then the two HTLCs would be (A^B)^C = R.

If the single replacement htlc fails then too and another thing needs to be tried, is the previous tree 'restored'?

No there is not need to restore the tree, the algo can continue splitting using A^B as the parent node and derive two child shares.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, looking at @halseth's branch clarified it. I think I've been seeing more of a tree than it really is.

@cfromknecht
Copy link
Contributor Author

So the receiver gets the cancel signal and cancels all htlcs back, but does the sender learn from that which htlcs were actually held by the receiver vs the ones that were still in-flight or didn't even reach the receiver? I suppose the sender can inspect the failure message to see what the origin of the failure is.

Yes, the source of the failure will tell the sender that all HTLCs were held by the receiver. If the sender intentionally fails reconstruction, they should get incorrect_payment_details on all of them as the reply.

Only when an htlc arrives at the receiver after the set has already been cancelled, and is then cancelled back, what does this look like to the sender? Will it be clear that that final htlc wasn't part of the set and reached the receiver "independently", possibly reusing liquidity that was just released?

The set is never cancelled unless the receiver has HTLCs satisfying the full amount. If any of the individual HTLCs are canceled early, that would happen with mpp_timeout.

@joostjager
Copy link
Contributor

The set is never cancelled unless the receiver has HTLCs satisfying the full amount. If any of the individual HTLCs are canceled early, that would happen with mpp_timeout.

Yes, so mean the sender-requested early cancellation of the set that was discussed ("ok, can you guys cancel things back now"). For example:

  • sender sends htlc A, B and C
  • htlc A and C reach the receiver
  • C contains the cancel-early flag
  • A and C are failed back with incorrect_payment_details
  • htlc B reaches the receiver
  • B is also failed back with incorrect_payment_details?

Doesn't it appear to the sender as if there is liquidity for A, B and C, while in reality it could be that there isn't enough for all three at the same time?

@halseth halseth mentioned this pull request Mar 31, 2021
2 tasks
@Roasbeef
Copy link
Member

Roasbeef commented Mar 31, 2021

Yes, so mean the sender-requested early cancellation of the set that was discussed ("ok, can you guys cancel things back now")

It may have changed in this diff, but last I checked the early cancel isn't yet implemented, though it was something we realized we could. The main gate to reconstruction is still having the full amount present across the set of HTLCS: https://github.com/cfromknecht/lnd/blob/sendtoroute-amp/invoices/update.go#L216.

edit: Though I think rn it's possible to still trigger the behavior by sending an mpp payload that has the wrong total amount that mismatches the others.

Re your question: I guess it depends on how the sender interprets the failures and if it's path finding is aware of multiple HTLCs sharing the same path or not. Or maybe I don't understand the scenario.

@Roasbeef Roasbeef enabled auto-merge April 6, 2021 03:19
@Roasbeef
Copy link
Member

Roasbeef commented Apr 6, 2021

Just needs a rebase!

// to create our AMP invoice.
payAddr := ctx.mpp.PaymentAddr()

// Create placeholder invoice.
Copy link
Contributor

Choose a reason for hiding this comment

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

After looking at lnd flame graphs for some time, I now realize that first creating and then updating the invoice is not ideal for maximum throughput. Everything database shows up prominently on the graph. Yes, for multiple shards it matters less, because multiple updates are needed anyway (although - isn't it possible to keep the invoice states fully in-memory until the set is complete? the htlcs are replayed anyway after a restart). But I can imagine that the majority of the cases will be successful single shard AMP payments. Especially when AMP turns out to be the platform of choice for implementing money streaming.

Copy link
Member

Choose a reason for hiding this comment

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

although - isn't it possible to keep the invoice states fully in-memory until the set is complete? the htlcs are replayed anyway after a restart

I guess we do it somewhat for record keeping purposes as well, since by updating it incrementally we can track the arrival, settle, and thus the "hodl" time for a given invoice across all the HTLCs. At a glance though, if we're willing to sacrifice that information, or collect it in an alternative manner (that may potentially be more lossy), I think the same potential optimization can be applied to MPP given that we'll also update the invoice incrementally (the state) as new HTLCs start to arrive.

Copy link
Member

Choose a reason for hiding this comment

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

This was also considered here: https://github.com/lightningnetwork/lnd/pull/5108/files/7a87a4c6627b6727facdfe1eb52aaeb5353f650a#r604347123

Worthy of a follow up if we don't think it's useful to know the history of prior failed attempts of a spontaneous payment.

This is to not conflict with trampoline BOLT.
Currently we use the AMP record type, this allows us to change it easily
as we add new known records. We also bump this to 0x0c instead of 0x0a.
Currently we support queries by payHash or payHash+payAddr. For handling
of AMP HTLCs, we only need to support querying by payAddr.
Also refactor existing unit tests to use them.
Adds a set of test cases that exercise the spontaneous AMP payment flow
with valid and invalid reconstructions, as well as with single and
multiple HTLCs. This also asserts that spontaneous AMP is gated behind
the existing AcceptKeysend flag.
This will be reused by the amp itest.
@Roasbeef Roasbeef merged commit c998264 into lightningnetwork:master Apr 7, 2021
@cfromknecht cfromknecht deleted the sendtoroute-amp branch April 13, 2021 17:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants