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

AMP support for SendPaymentV2 #5159

Merged
merged 17 commits into from
Apr 27, 2021

Conversation

halseth
Copy link
Contributor

@halseth halseth commented Mar 31, 2021

This PR implements AMP payment support for the SendPaymentV2 API. Note that all details must still be specified manually, no payment request support is added yet.

Builds on #5108

TODO

  • Itest
  • Unit tests

// payments this will be the used SetID.
func (l *LightningPayment) Identifier() [32]byte {
if l.amp != nil {
return l.amp.SetID
Copy link
Contributor Author

Choose a reason for hiding this comment

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

so the question is whether this is an okay identifier for the payment. This is what ill be used by the control tower to only allow a single payment using this ID. We could as discussed use this with a combination of the paymentAddress, but I think we want to allow several payments going to the same payment address?

Copy link
Contributor

Choose a reason for hiding this comment

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

yeah i think just using the setid is fine, before we discussed doing (pay_addr, set_id) but i think this is actually better. enforces that no two payments will ever have the same set_id, even if they are to different pay_addrs

@halseth halseth requested review from Roasbeef and removed request for joostjager March 31, 2021 14:05
@Roasbeef Roasbeef added this to the 0.13.0 milestone Mar 31, 2021
@Roasbeef Roasbeef added amp enhancement Improvements to existing features / behaviour routing labels Mar 31, 2021
Copy link
Contributor

@cfromknecht cfromknecht 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 quick pass, so far changes look awesome 🔥

amp/shard_tracker.go Outdated Show resolved Hide resolved
amp/shard_tracker.go Show resolved Hide resolved
amp/shard_tracker.go Outdated Show resolved Hide resolved
amp/shard_tracker.go Outdated Show resolved Hide resolved

// If this is an AMP payment, we'll use the AMP shard tracker.
case payment.amp != nil:
shardTracker = amp.NewShardTracker(
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 noice

lntest/itest/lnd_amp_test.go Outdated Show resolved Hide resolved
lnrpc/routerrpc/router_backend.go Show resolved Hide resolved
// payments this will be the used SetID.
func (l *LightningPayment) Identifier() [32]byte {
if l.amp != nil {
return l.amp.SetID
Copy link
Contributor

Choose a reason for hiding this comment

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

yeah i think just using the setid is fine, before we discussed doing (pay_addr, set_id) but i think this is actually better. enforces that no two payments will ever have the same set_id, even if they are to different pay_addrs

require.NoError(t.t, err)

ctxt, _ := context.WithTimeout(context.Background(), 4*defaultTimeout)
payment := sendAndAssertSuccess(
Copy link
Contributor

Choose a reason for hiding this comment

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

really digging how concise this itest is, almost as if we skipped a step 😎

}

// Use a random child index.
var childIndex [4]byte
Copy link
Contributor

Choose a reason for hiding this comment

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

With a 32-bit random value, isn't the risk of a collision too high? Maybe the htlc id or the pid can be used here or a set-specific incrementing id.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I guess it depends on how it is being used. Currently a child gets created, and if it fails we xor the share back into the parent. This means that it will be split again most likely (which adds 32 byte randomness) before a new shard is sent.

In the scenario where it's the last shard that is being created, we can end up deriving multiple children from it. However, you must send a lot of attempts before a collision is likely. Maybe that potential privacy leak is not worth optimizing out? (since I think using the same index only would be a privacy concern)

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, just wanted to bring it up. I think it is safe to assume that overall the vast majority of the payments will be single shot and in a post-hyperbitcoinization world with money streaming from every netflix subscriber - maybe collisions happen.

Copy link
Contributor

Choose a reason for hiding this comment

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

And what about adding a counter field to the last share struct? That doesn't leak info about other payments to the receiver.

Copy link
Contributor

Choose a reason for hiding this comment

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

at 32-bits, we expect a payment hash collision after 2^16 retries of the same HTLC. even at 1 sec per attempt, that's continually resending the same AMP HTLC for 18 hours. AMP HTLCs with different shares shouldn't ever collide in practice because they have plenty of entropy.

if we are really concerned about collisions, we could use a CTR mode (random offset + increment) and then we'd only get collisions on wrap around. in the prior example—after 136 years of sending the same HTLC. in the end, the worst that happens is payment hash reuse. if a new use case comes along that might require this sort of sustained retry behavior then we can easily switch to CTR without any protocol changes

Copy link
Contributor

Choose a reason for hiding this comment

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

But what is wrong with my suggestion to use a counter to not have this problem at all, without sacrificing privacy?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

More, real work, for only a theoretical gain? :p

Copy link
Member

Choose a reason for hiding this comment

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

in a post-hyperbitcoinization world with money streaming from every netflix subscriber - maybe collisions happen.

In case it isn't clear, this index is just within the context of a given setID. So the ensure that all the world streaming netflix payment doesn't collide, we just need to ensure that the setID is unique, which easy given the negligible probability of colliding 256-bit integers.

Ultimately, with the route Johan has taken here with the implementation, since we just XOR out the failed shares rather than increment the childIndex (which would result in a distinct payment hash for a same share), we don't really need to worry about this colliding in practice. The alternative would've been to get another random child index, for the same share, then try again (uses a diff payment hash, but same share). This alternative route would result in more collisions in practice.

Copy link
Contributor

Choose a reason for hiding this comment

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

The context was clear. I just meant to say that if massive amounts of payments happen, the overall chance of a collision somewhere on the earth increases. It was totally clear that independent payments cannot collide. Let's say in some time frame, 2^32 independent payments happen that need a second attempt. There will then be on average one user that experiences a collision.

The out-xor'ing does not apply to the final share and I'd say that almost all AMP payments will be single-shot and have only a final share that may be retried with a different child index.

The alternative would've been to get another random child index, for the same share, then try again (uses a diff payment hash, but same share). This alternative route would result in more collisions in practice.

So what I am proposing is to just keep a counter for each (split) share that is generated. So there will be a counter within the context of a final share. Then keep incrementing that with each attempt. Then there is no risk of collision, so in my opinion a better option.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is this like something you had in mind? #5255

However there is still the case that after 2^32 "last shards" the counter will wrap around and collisions will start happening.

s.shards[pid] = child

mpp := record.NewMPP(s.totalAmt, s.paymentAddr)
amp := record.NewAMP(
Copy link
Contributor

Choose a reason for hiding this comment

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

Question about record.NewAMP: Why is the first parameter named rootShare? Shouldn't it be just share?

Copy link
Contributor

Choose a reason for hiding this comment

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

at the time i think I used that as short hand for shareOfRoot, but yes just using share would be better

// zero share to indicate we cannot split it further.
s.sharer.curr = zeroShare
} else {
left, sharer, err := s.sharer.Split()
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor comment: The naming left and right led me to think that the xor tree is more than it really is. Somehow I expected left and right to be different. That there is some kind of condition that determines what's left and what's right. Could be that I am the only one that has this.

Perhaps something like share and remainder could work too.

Copy link
Member

Choose a reason for hiding this comment

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

It can be hard to grok at first as there's actually less enforced structure than one might think. For example, there's no requirement that we split using our current right-skewed binary tree. Instead we could opt to make it more balanced. With the way Split() works, left is a random value while right is the parent xor'd with that random value. Rather then incrementing the child index for left (which will generate a new payment hash but keep the same share), this PR instead "destroys" left by XOR'ing that value out of right, right can then be split again to get new randomness and retry the payment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I found it easier to just visualize it as a set off pebbles that we can split and merge at will.

A tree is perhaps not the best description, as we can also merge leafs from different sub-branches IIUC? @cfromknecht

Copy link
Contributor

Choose a reason for hiding this comment

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

Indeed, something like a cord that you can cut and glue together again matches much better with how I assume it works.

return nil, err
}

s.sharer = sharer.(*SeedSharer)
Copy link
Contributor

Choose a reason for hiding this comment

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

Another option could be that sharer.Split splits itself (maybe sharer.Spawn?). It returns a single share and updates its internal share to be the remainder. Then it isn't necessary to create a new instance. I think it could be easier to understand how it works that way.

htlcswitch/switch.go Outdated Show resolved Hide resolved
routing/payment_lifecycle.go Outdated Show resolved Hide resolved
routing/payment_lifecycle.go Show resolved Hide resolved
routing/shards/shard_tracker.go Show resolved Hide resolved
amp/shard_tracker.go Show resolved Hide resolved
amp/shard_tracker.go Show resolved Hide resolved
if features != nil {
err = feature.ValidateDeps(features)
if err != nil {
return nil, err
Copy link
Member

Choose a reason for hiding this comment

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

This is an opportunity to start returning better information back to the caller to explain why the payment can't succeed. As a test I tried to pay Conner's AMP invoice from my unupgraded node and got "no routes found" on the command line rather than "payment type unsupported" or something like that.

In order to do this, we'd need to add the new error return type in path finding itself if we fail to find a path due to a destination node's feature incompatibility. Alternatively, we can do a check for dest feature bit compatibility much earlier in the cycle.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think that would mean it actually did not find a route? Since if it did, the destination node should send an error message because of the missing AMP payload, which would be the error you would see.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah so I think we'll end up doing this in the follow up PR that adds the basic invoicing information. In either case, this would've ideally been done in the prior release so older nodes will, know they can't pay an invoice since it's AMP, and not that there doesn't exist a path.

lnrpc/routerrpc/router_backend.go Show resolved Hide resolved
lntest/itest/lnd_amp_test.go Outdated Show resolved Hide resolved
@halseth halseth force-pushed the amp-sendpayment branch 6 times, most recently from 8a7aa7c to b492a7f Compare April 8, 2021 19:34
@Roasbeef
Copy link
Member

Roasbeef commented Apr 9, 2021

Did some payments on testnet using this branch, here're my findinsg:

  • Works as advertised!
  • This PR is missing basic CLI support, here's a hacky commit to enable that: eaa8a10
  • We don't set the AMP records in the LIstPayments response, so there's no way to distinguish an AMP payment from a regular one from the PoV of that API.
  • Right now the --accept-keysend flag is needed in order to be able to receive, we should mirror it with something AMP specific to make it a bit clearer.
  • The ListInvoices response has a is_keysend field, do we also want to mirror this w/ AMP? If some users are relying on this field right now (like maybe Sphinx Chat?), then perhaps we should also always set for AMP payments to smooth the transition.

@cfromknecht
Copy link
Contributor

cfromknecht commented Apr 9, 2021

The ListInvoices response has a is_keysend field, do we also want to mirror this w/ AMP? If some users are relying on this field right now (like maybe Sphinx Chat?), then perhaps we should also always set for AMP payments to smooth the transition.

I have this item in a separate branch, but we can move them to this PR if we would rather have it here.

EDIT: scratch that, my CLI commit is an --amp flag for the AddInvoice side, still need the one for SendPayment 👍

@halseth halseth force-pushed the amp-sendpayment branch 2 times, most recently from 0c2d86b to f86e3a9 Compare April 12, 2021 14:05
@halseth
Copy link
Contributor Author

halseth commented Apr 12, 2021

Ok, this is now ready for final review, pushed unit tests for the new modules.

  • This PR is missing basic CLI support, here's a hacky commit to enable that: eaa8a10
  • We don't set the AMP records in the LIstPayments response, so there's no way to distinguish an AMP payment from a regular one from the PoV of that API.
  • Right now the --accept-keysend flag is needed in order to be able to receive, we should mirror it with something AMP specific to make it a bit clearer.
  • The ListInvoices response has a is_keysend field, do we also want to mirror this w/ AMP? If some users are relying on this field right now (like maybe Sphinx Chat?), then perhaps we should also always set for AMP payments to smooth the transition.

Yeah, lets create another PR for the CLI/RPC changes.

@Roasbeef
Copy link
Member

@halseth SGTM, so then will we address the above comments in @cfromknecht's upcoming PR?

@Roasbeef
Copy link
Member

Linter failing rn:

amp/derivation_test.go:15:2: U1000: field `zeroShare` is unused (unused)
	zeroShare bool
	^
amp/derivation_test.go:102:3: SA4006: this value of `sharer` is never used (staticcheck)
		sharer = sharer.Zero()

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.

LGTM 🍃

@@ -165,17 +165,21 @@ type PaymentAttemptDispatcher interface {
// denoted by its public key. A non-nil error is to be returned if the
// payment was unsuccessful.
SendHTLC(firstHop lnwire.ShortChannelID,
paymentID uint64,
attemptID uint64,
Copy link
Member

Choose a reason for hiding this comment

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

Solid change 👍

// Now that we are failing this payment attempt, cancel the shard with
// the ShardTracker such that it can derive the correct hash for the
// next attempt.
if err := p.shardTracker.CancelShard(attempt.AttemptID); err != nil {
Copy link
Member

Choose a reason for hiding this comment

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

+1 for adding this to the main interface, makes the control flow a lot easier to fllow


// TestAMPShardTracker tests that we can derive and cancel shards at will using
// the AMP shard tracker.
func TestAMPShardTracker(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

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

👍

routing/router.go Outdated Show resolved Hide resolved
}

// Use a random child index.
var childIndex [4]byte
Copy link
Member

Choose a reason for hiding this comment

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

in a post-hyperbitcoinization world with money streaming from every netflix subscriber - maybe collisions happen.

In case it isn't clear, this index is just within the context of a given setID. So the ensure that all the world streaming netflix payment doesn't collide, we just need to ensure that the setID is unique, which easy given the negligible probability of colliding 256-bit integers.

Ultimately, with the route Johan has taken here with the implementation, since we just XOR out the failed shares rather than increment the childIndex (which would result in a distinct payment hash for a same share), we don't really need to worry about this colliding in practice. The alternative would've been to get another random child index, for the same share, then try again (uses a diff payment hash, but same share). This alternative route would result in more collisions in practice.

@halseth
Copy link
Contributor Author

halseth commented Apr 15, 2021

fixed, but travis having a bad day

@Roasbeef
Copy link
Member

Needs a rebase!

halseth added 3 commits April 27, 2021 08:27
We might as well return all info, and we'll need the individual HTLCs
in later commits.
To distinguish the attempt's unique ID from the overall payment
identifier, we name it attemptID everywhere, and note that the
paymentHash argument won't be the actual payment hash for AMP payments.
We'll use this to keep track of the outstanding shards and which
preimages we are using for each. For now this is a simple map from
attempt ID to hash, but later we'll hide the AMP child derivation behind
this interface.
halseth added 14 commits April 27, 2021 09:43
We'll let the payment's lifecycle register each shard it's sending with
the ShardTracker, canceling failed shards. This will be the foundation
for correct AMP derivation for each shard we'll send.
For AMP payments the hash used for each HTLC will differ, and we will
need to retrive it after a restart. We therefore persist it with each
attempt.
We'll use this AMP-specific ShardTracker for AMP payments.  It will be
used to derive hashes for each HTLC attempt using the underlying AMP
derivation scheme.
We'll use the AMP-specific ShardTracker for payments having non-nil
AMPOptions.
For now this is how you indicate you want to perform an AMP payment to
the destination.
Since we want to support AMP payment using a different unique payment
identifier (AMP payments don't go to one specific hash), we change the
nomenclature to be Identifier instead of PaymentHash.
Copy link
Contributor

@cfromknecht cfromknecht left a comment

Choose a reason for hiding this comment

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

Did another pass, I think this is ready to land! LGTM 🥳

@cfromknecht cfromknecht merged commit d771ed7 into lightningnetwork:master Apr 27, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
amp enhancement Improvements to existing features / behaviour routing
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants