-
Notifications
You must be signed in to change notification settings - Fork 2.1k
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
routing: inbound fees support for BuildRoute #8886
routing: inbound fees support for BuildRoute #8886
Conversation
Important Review skippedAuto reviews are limited to specific labels. Labels to auto review (1)
Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media? TipsChatThere are 3 ways to chat with CodeRabbit:
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 as PR comments)
Additionally, you can add CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So this is my first time looking at this corner of the codebase, so the quality of my feedback may not be all that great but here is what I've come to understand.
The algorithm seems to be intrinsically imperative and essentially must be computed iteratively. Normally I hate this but in order to satisfy MinHTLC requirements I don't think there is a way around that. Given that the algorithm must be imperative, I think it is extremely important to be specific about the role of each variable. Unfortunately I'm not completely clear on precisely how we are computing things. My understanding is that there are broadly two cases that we need to consider:
- We want to solve for the smallest amount we need to send to allow the receiver to successfully receive 1 sat.
- We want to compute the amount that we need to send in order for the receiver to receive some other amount ignoring MinHTLC requirements. NOTE: I find it odd that we ignore MinHTLC requirements in this second case but it is a consequence of how the code is currently written, it's possible this wasn't intended and if so should be addressed.
From here It comes to my attention that the following expressions should hold:
sendAmt := recvAmt + fees
fees := outboundFees + inboundFees
outboundFees := fn.Sum(fn.Map(getOutboundFee, edges[1:]))
inboundFees := fn.Sum(fn.Map(getInboundFee, edges[:len(edges)-1]))
Separately, I have a few notes about naming things. Functions should be named according to the properties they satisfy, not based off of the procedure they follow. This is because the caller does not care about how a computation is carried out, they care about what computation is being done. Similarly, for variables, I'd recommend (though I do not insist on) naming them according to the eventual result they will produce in the case of accumulators, and for purely transient variables, you can drop things like "next" or "temp" and just favor using your character budget on being specific about what value is under that iterator.
I understand that a substantial portion of what I'm commenting on precedes this PR, however it is hard for me to comment on the quality of the diff without implicitly commenting on the quality of the existing codebase. Improving some of these things may be beyond the desired scope of this PR and in that case it's OK. However, in other parts of LND I've been favoring the approach of "no PR should make the quality of the codebase deteriorate".
Lmk if you think this is reasonable and attainable. If not, maybe we can converge on something that improves the codebase incrementally while not distracting us too much from the main feature goal.
There was a problem hiding this 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 to load context. Refactor work looks good to me 👍
But looks like the actual calculation has some TODOs left, so will revisit once ready.
@@ -59,6 +59,9 @@ | |||
* [`ChanInfoRequest`](https://github.com/lightningnetwork/lnd/pull/8813) | |||
adds support for channel points. | |||
|
|||
* [BuildRoute](https://github.com/lightningnetwork/lnd/pull/8886) now supports |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Need to move those to the 0.18.3 file now.
routing/router.go
Outdated
incoming, outgoing *unifiedEdge) lnwire.MilliSatoshi { | ||
|
||
// TODO: calculations with integer ceil/floor arithmetic | ||
feeRateParts := 1e6 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can just keep everything multiplied by a million and then do integer division later on?
Not sure about the requirements of the actual algorithm, I just know we should try to avoid float64
wherever possible!
But I guess that's exactly what the TODOs here are for.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was kind of tough to do integer arithmetic here because formulas involve products of PPM * PPM * amount
, which can easily overflow a int64 and we need to handle negative numbers as well. I tried an approach with floats, they at least don't have a problem overflowing when adding/multiplying numbers and I think there are no problems related to accumulating errors, but not too knowledgeable here. Another option would be to use big.Int
, but the syntax there looks very clumsy
We split up the functionality in getRouteUnifiers into checking that all edges exist via getEdgeUnifiers and then add a backward pass that will be responsible for determining the sender amount. We remove the node pub key from the error string, as in route building this is duplicate info, which can be determined from the input keys, further it's not available in the backward pass anymore. We refactor the BuildRoute test to use the require library and add a test case for a max HTLC violation on the last hop.
eb4cf50
to
3b15f4c
Compare
Thanks for the reviews @guggero and @ProofOfKeags and also for the improvement suggestions!
Yes pretty well described 👍
Roughly yes, unfortunately the inbound fees are coupled to the previous hop's outbound fees, which is why I think the map approach wouldn't work well. I added some documentation to the amount calculation, hope that helps. I'm still using float numerics, but need to look into that again to be sure it is precise and what the boundaries of failures would be.
Totally agree, happy to address things! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Much improved. I still have some stuff I'd like to see addressed, particularly around testing and floating point arithmetic.
// Ai(Ao) can potentially become more negative in amplitude than Ao, | ||
// which is why the above mentioned capping is needed. We can abbreviate | ||
// Ai(Ao) with Ai(Ao) = m*Ao + n, where m and n are: | ||
// m := (1 + Ro/PPM) * (1 + Ri/PPM) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this expression only holds in integer space, not float space.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not sure I follow, could you explain that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What I mean is that division has different composability properties when done in integer space vs real space. For instance: A == (A / B) * B
is not an invariant that holds in integer space. As such, I believe that the expression (1 + Ro/PPM) * (1 + Ri/PPM)
is an expression that is only valid when we are doing integer math. I think that now that you've refactored towards using bigints this should be fine and we can probably ignore my comment above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, I'm still a bit confused by the statement, since I'd rather say it's accurate in real space (up to a cast to compare to the int result) but not in integer space as you need to do an integer division twice with the accompanied rounding, but good it's resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought the reason we add the 1 in the first place is because of the truncation of the remainder in integer space. By keeping it in real space I do not believe we need the 1 +
part of the expression. Maybe I misunderstand.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not about rounding here, it's the exact formula, but factored to see how the fee rates compare to 1, to see if it results in a zero or negative overall term, see the og derivation https://github.com/feelancer21/lnd/blob/f6f05fa930985aac0d27c3f6681aada1b599162a/prototype/doc.md#outgoing---incoming.
3b15f4c
to
1021d3e
Compare
We duplicate the function calls to handle the min amount and known amount cases in a similar manner, to make the next diff easier to parse.
We shift the duty of determining the policies to the backward pass as the forward pass will only be responsible for finding the corrected receiver amount. Note that this is not a pure refactor as demonstrated in the test, as the forward pass doesn't select new policies anymore, which is less flexible and doesn't lead to the highest possible receiver amount. This is however neccessary as we otherwise won't be able to compute forwarding amounts involving inbound fees and this edge case is unlikely to occur, because we search for a min amount for a route that was most likely constructed for a larger amount.
1021d3e
to
55c590b
Compare
I switched |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Super close at this point.
// Ai(Ao) can potentially become more negative in amplitude than Ao, | ||
// which is why the above mentioned capping is needed. We can abbreviate | ||
// Ai(Ao) with Ai(Ao) = m*Ao + n, where m and n are: | ||
// m := (1 + Ro/PPM) * (1 + Ri/PPM) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What I mean is that division has different composability properties when done in integer space vs real space. For instance: A == (A / B) * B
is not an invariant that holds in integer space. As such, I believe that the expression (1 + Ro/PPM) * (1 + Ri/PPM)
is an expression that is only valid when we are doing integer math. I think that now that you've refactored towards using bigints this should be fine and we can probably ignore my comment above.
55c590b
to
8c779d8
Compare
error) { | ||
// getEdgeUnifiers returns a list of edge unifiers for the given route. | ||
func getEdgeUnifiers(source route.Vertex, hops []route.Vertex, | ||
outgoingChans map[uint64]struct{}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fwiw this is is a fn.Set[uint64]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, a set would be nice here. But changing that would also require a change in newNodeEdgeUnifier
which is used in a couple of places. So non-blocking IMO, don't want to turn this into a big type refactor PR.
// Ai(Ao) can potentially become more negative in amplitude than Ao, | ||
// which is why the above mentioned capping is needed. We can abbreviate | ||
// Ai(Ao) with Ai(Ao) = m*Ao + n, where m and n are: | ||
// m := (1 + Ro/PPM) * (1 + Ri/PPM) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought the reason we add the 1 in the first place is because of the truncation of the remainder in integer space. By keeping it in real space I do not believe we need the 1 +
part of the expression. Maybe I misunderstand.
routing/router.go
Outdated
receiverAmt, err := receiverAmtForwardPass( | ||
senderAmt, pathEdges, | ||
) | ||
|
||
receiverAmt = *amt | ||
return fn.NewResult(receiverAmt, err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I learned from @Roasbeef that you can actually just do this and it mysteriously works:
func() fn.Result[lnwire.MilliSatoshi] {
return fn.NewResult(
receiverAmtForwardPass(
senderAmt, pathEdges,
)
)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks very nice!
Two questions and a nit, other than that LGTM 🎉
error) { | ||
// getEdgeUnifiers returns a list of edge unifiers for the given route. | ||
func getEdgeUnifiers(source route.Vertex, hops []route.Vertex, | ||
outgoingChans map[uint64]struct{}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, a set would be nice here. But changing that would also require a change in newNodeEdgeUnifier
which is used in a couple of places. So non-blocking IMO, don't want to turn this into a big type refactor PR.
localChan := i == 0 | ||
edgeUnifier := unifiers[i] | ||
// We traverse the route backwards and handle the last hop separately. | ||
edgeUnifier := unifiers[len(unifiers)-1] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need a bounds check here or are we certain there's always at least one edge?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice, I added a check here and on the RPC level (new commit)
routing/router_test.go
Outdated
tt := tt | ||
|
||
t.Run(tt.name, func(t *testing.T) { | ||
testInboundOutboundFee(t, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
micro-nit: t
on new line. Also, usually we use tc
for test cases and tt
for sub *testing.T
within a t.Run()
. But really inconsequential, feel free to ignore.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cool, I never realized that pattern, updated 🙏
Adds a utility function to be able to compute the outgoing routing amount from the incoming amount by taking inbound and outbound fees into account. The discussion was contributed by user feelancer21, see feelancer21@f6f05fa.
8c779d8
to
65d7515
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM 🎉
65d7515
to
e5bbe27
Compare
I noticed that when constructing the edge unifiers, we always included inbound fees in the policy. In pathfinding we don't set them to be active for the last hop, which is important for edge constraint checks (updated and documented in |
e5bbe27
to
f622b43
Compare
This PR attempts to add support for inbound fees to
BuildRoute
. The three passesBuildRoute
goes through (see #8814 (comment) for an explanation) are altered to determine the edges in the backward pass (from receiver to sender) as opposed to the previous approach where this is done in the forward pass (sender to receiver). This way we get fixed edges that can be used to determine the minimal sender/maximal receiver amount with inbound fees, doing a reverse amount calculation (thanks to @feelancer21 for the analysis #8814 (comment)). The computation for the outgoing amount based on the incoming amount is done usingbig.Int
arithmetic.Previous work on this: #7060