Skip to content

Commit

Permalink
Trampoline payments use per-channel fee and cltv
Browse files Browse the repository at this point in the history
  • Loading branch information
thomash-acinq committed Jun 30, 2021
1 parent 85ed433 commit 42dd0db
Show file tree
Hide file tree
Showing 9 changed files with 49 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,13 @@ object NodeRelay {

/** Compute route params that honor our fee and cltv requirements. */
def computeRouteParams(nodeParams: NodeParams, amountIn: MilliSatoshi, expiryIn: CltvExpiry, amountOut: MilliSatoshi, expiryOut: CltvExpiry): RouteParams = {
val routeMaxCltv = expiryIn - expiryOut - nodeParams.expiryDelta
val routeMaxFee = amountIn - amountOut - nodeFee(nodeParams.feeBase, nodeParams.feeProportionalMillionth, amountOut)
val routeMaxCltv = expiryIn - expiryOut
val routeMaxFee = amountIn - amountOut
RouteCalculation.getDefaultRouteParams(nodeParams.routerConf).copy(
maxFeeBase = routeMaxFee,
routeMaxCltv = routeMaxCltv,
maxFeePct = 0 // we disable percent-based max fee calculation, we're only interested in collecting our node fee
maxFeePct = 0, // we disable percent-based max fee calculation, we're only interested in collecting our node fee
isRelay = true,
)
}

Expand Down
32 changes: 19 additions & 13 deletions eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ object Graph {
* @param wr ratios used to 'weight' edges when searching for the shortest path
* @param currentBlockHeight the height of the chain tip (latest block)
* @param boundaries a predicate function that can be used to impose limits on the outcome of the search
* @param isRelay if the path is for relaying
*/
def yenKshortestPaths(graph: DirectedGraph,
sourceNode: PublicKey,
Expand All @@ -95,10 +96,11 @@ object Graph {
pathsToFind: Int,
wr: Option[WeightRatios],
currentBlockHeight: Long,
boundaries: RichWeight => Boolean): Seq[WeightedPath] = {
boundaries: RichWeight => Boolean,
isRelay: Boolean): Seq[WeightedPath] = {
// find the shortest path (k = 0)
val targetWeight = RichWeight(amount, 0, CltvExpiryDelta(0), 0)
val shortestPath = dijkstraShortestPath(graph, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, currentBlockHeight, wr)
val shortestPath = dijkstraShortestPath(graph, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, currentBlockHeight, wr, isRelay)
if (shortestPath.isEmpty) {
return Seq.empty // if we can't even find a single path, avoid returning a Seq(Seq.empty)
}
Expand All @@ -110,7 +112,7 @@ object Graph {

var allSpurPathsFound = false
val shortestPaths = new mutable.Queue[PathWithSpur]
shortestPaths.enqueue(PathWithSpur(WeightedPath(shortestPath, pathWeight(sourceNode, shortestPath, amount, currentBlockHeight, wr)), 0))
shortestPaths.enqueue(PathWithSpur(WeightedPath(shortestPath, pathWeight(sourceNode, shortestPath, amount, currentBlockHeight, wr, isRelay)), 0))
// stores the candidates for the k-th shortest path, sorted by path cost
val candidates = new mutable.PriorityQueue[PathWithSpur]

Expand All @@ -135,12 +137,12 @@ object Graph {
val alreadyExploredEdges = shortestPaths.collect { case p if p.p.path.takeRight(i) == rootPathEdges => p.p.path(p.p.path.length - 1 - i).desc }.toSet
// we also want to ignore any vertex on the root path to prevent loops
val alreadyExploredVertices = rootPathEdges.map(_.desc.b).toSet
val rootPathWeight = pathWeight(sourceNode, rootPathEdges, amount, currentBlockHeight, wr)
val rootPathWeight = pathWeight(sourceNode, rootPathEdges, amount, currentBlockHeight, wr, isRelay)
// find the "spur" path, a sub-path going from the spur node to the target avoiding previously found sub-paths
val spurPath = dijkstraShortestPath(graph, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, currentBlockHeight, wr)
val spurPath = dijkstraShortestPath(graph, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, currentBlockHeight, wr, isRelay)
if (spurPath.nonEmpty) {
val completePath = spurPath ++ rootPathEdges
val candidatePath = WeightedPath(completePath, pathWeight(sourceNode, completePath, amount, currentBlockHeight, wr))
val candidatePath = WeightedPath(completePath, pathWeight(sourceNode, completePath, amount, currentBlockHeight, wr, isRelay))
candidates.enqueue(PathWithSpur(candidatePath, i))
}
}
Expand Down Expand Up @@ -173,6 +175,7 @@ object Graph {
* @param boundaries a predicate function that can be used to impose limits on the outcome of the search
* @param currentBlockHeight the height of the chain tip (latest block)
* @param wr ratios used to 'weight' edges when searching for the shortest path
* @param isRelay if the path is for relaying
*/
private def dijkstraShortestPath(g: DirectedGraph,
sourceNode: PublicKey,
Expand All @@ -183,7 +186,8 @@ object Graph {
initialWeight: RichWeight,
boundaries: RichWeight => Boolean,
currentBlockHeight: Long,
wr: Option[WeightRatios]): Seq[GraphEdge] = {
wr: Option[WeightRatios],
isRelay: Boolean): Seq[GraphEdge] = {
// the graph does not contain source/destination nodes
val sourceNotInGraph = !g.containsVertex(sourceNode) && !extraEdges.exists(_.desc.a == sourceNode)
val targetNotInGraph = !g.containsVertex(targetNode) && !extraEdges.exists(_.desc.b == targetNode)
Expand Down Expand Up @@ -221,7 +225,7 @@ object Graph {
val neighbor = edge.desc.a
// NB: this contains the amount (including fees) that will need to be sent to `neighbor`, but the amount that
// will be relayed through that edge is the one in `currentWeight`.
val neighborWeight = addEdgeWeight(sourceNode, edge, current.weight, currentBlockHeight, wr)
val neighborWeight = addEdgeWeight(sourceNode, edge, current.weight, currentBlockHeight, wr, isRelay)
val canRelayAmount = current.weight.cost <= edge.capacity &&
edge.balance_opt.forall(current.weight.cost <= _) &&
edge.update.htlcMaximumMsat.forall(current.weight.cost <= _) &&
Expand Down Expand Up @@ -263,11 +267,12 @@ object Graph {
* @param prev weight of the rest of the path
* @param currentBlockHeight the height of the chain tip (latest block).
* @param weightRatios ratios used to 'weight' edges when searching for the shortest path
* @param isRelay if the path is for relaying
*/
private def addEdgeWeight(sender: PublicKey, edge: GraphEdge, prev: RichWeight, currentBlockHeight: Long, weightRatios: Option[WeightRatios]): RichWeight = {
val totalCost = if (edge.desc.a == sender) prev.cost else addEdgeFees(edge, prev.cost)
private def addEdgeWeight(sender: PublicKey, edge: GraphEdge, prev: RichWeight, currentBlockHeight: Long, weightRatios: Option[WeightRatios], isRelay: Boolean): RichWeight = {
val totalCost = if (edge.desc.a == sender && !isRelay) prev.cost else addEdgeFees(edge, prev.cost)
val fee = totalCost - prev.cost
val totalCltv = if (edge.desc.a == sender) prev.cltv else prev.cltv + edge.update.cltvExpiryDelta
val totalCltv = if (edge.desc.a == sender && !isRelay) prev.cltv else prev.cltv + edge.update.cltvExpiryDelta
val factor = weightRatios match {
case None =>
1.0
Expand Down Expand Up @@ -327,10 +332,11 @@ object Graph {
* @param amount amount to send to the last node.
* @param currentBlockHeight the height of the chain tip (latest block).
* @param wr ratios used to 'weight' edges when searching for the shortest path
* @param isRelay if the path is for relaying
*/
def pathWeight(sender: PublicKey, path: Seq[GraphEdge], amount: MilliSatoshi, currentBlockHeight: Long, wr: Option[WeightRatios]): RichWeight = {
def pathWeight(sender: PublicKey, path: Seq[GraphEdge], amount: MilliSatoshi, currentBlockHeight: Long, wr: Option[WeightRatios], isRelay: Boolean): RichWeight = {
path.foldRight(RichWeight(amount, 0, CltvExpiryDelta(0), 0)) { (edge, prev) =>
addEdgeWeight(sender, edge, prev, currentBlockHeight, wr)
addEdgeWeight(sender, edge, prev, currentBlockHeight, wr, isRelay)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ object RouteCalculation {
capacityFactor = routerConf.searchRatioChannelCapacity
))
},
mpp = MultiPartParams(routerConf.mppMinPartAmount, routerConf.mppMaxParts)
mpp = MultiPartParams(routerConf.mppMinPartAmount, routerConf.mppMaxParts),
isRelay = false,
)

/**
Expand Down Expand Up @@ -257,7 +258,7 @@ object RouteCalculation {

val boundaries: RichWeight => Boolean = { weight => feeOk(weight.cost - amount) && lengthOk(weight.length) && cltvOk(weight.cltv) }

val foundRoutes: Seq[Graph.WeightedPath] = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amount, ignoredEdges, ignoredVertices, extraEdges, numRoutes, routeParams.ratios, currentBlockHeight, boundaries)
val foundRoutes: Seq[Graph.WeightedPath] = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amount, ignoredEdges, ignoredVertices, extraEdges, numRoutes, routeParams.ratios, currentBlockHeight, boundaries, routeParams.isRelay)
if (foundRoutes.nonEmpty) {
val (directRoutes, indirectRoutes) = foundRoutes.partition(_.path.length == 1)
val routes = if (routeParams.randomize) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ object Router {

case class MultiPartParams(minPartAmount: MilliSatoshi, maxParts: Int)

case class RouteParams(randomize: Boolean, maxFeeBase: MilliSatoshi, maxFeePct: Double, routeMaxLength: Int, routeMaxCltv: CltvExpiryDelta, ratios: Option[WeightRatios], mpp: MultiPartParams) {
case class RouteParams(randomize: Boolean, maxFeeBase: MilliSatoshi, maxFeePct: Double, routeMaxLength: Int, routeMaxCltv: CltvExpiryDelta, ratios: Option[WeightRatios], mpp: MultiPartParams, isRelay: Boolean) {
def getMaxFee(amount: MilliSatoshi): MilliSatoshi = {
// The payment fee must satisfy either the flat fee or the percentage fee, not necessarily both.
maxFeeBase.max(amount * maxFeePct)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ abstract class IntegrationSpec extends TestKitBaseClass with BitcoindService wit
ageFactor = 0,
capacityFactor = 0
)),
mpp = MultiPartParams(15000000 msat, 6)
mpp = MultiPartParams(15000000 msat, 6),
isRelay = false,
))

// we need to provide a value higher than every node's fulfill-safety-before-timeout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ object MultiPartPaymentLifecycleSpec {
val expiry = CltvExpiry(1105)
val finalAmount = 1000000 msat
val finalRecipient = randomKey().publicKey
val routeParams = RouteParams(randomize = false, 15000 msat, 0.01, 6, CltvExpiryDelta(1008), None, MultiPartParams(1000 msat, 5))
val routeParams = RouteParams(randomize = false, 15000 msat, 0.01, 6, CltvExpiryDelta(1008), None, MultiPartParams(1000 msat, 5), false)
val maxFee = 15000 msat // max fee for the defaultAmount

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ class PaymentLifecycleSpec extends BaseRouterSpec {
import payFixture._
import cfg._

val request = SendPayment(sender.ref, d, Onion.createSinglePartPayload(defaultAmountMsat, defaultExpiry, defaultInvoice.paymentSecret.get), 5, routeParams = Some(RouteParams(randomize = false, 100 msat, 0.0, 20, CltvExpiryDelta(2016), None, MultiPartParams(10000 msat, 5))))
val request = SendPayment(sender.ref, d, Onion.createSinglePartPayload(defaultAmountMsat, defaultExpiry, defaultInvoice.paymentSecret.get), 5, routeParams = Some(RouteParams(randomize = false, 100 msat, 0.0, 20, CltvExpiryDelta(2016), None, MultiPartParams(10000 msat, 5), false)))
sender.send(paymentFSM, request)
val routeRequest = routerForwarder.expectMsgType[RouteRequest]
val Transition(_, WAITING_FOR_REQUEST, WAITING_FOR_ROUTE) = monitor.expectMsgClass(classOf[Transition[_]])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -487,10 +487,10 @@ class NodeRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("appl

val routeRequest = router.expectMessageType[RouteRequest]
val routeParams = routeRequest.routeParams.get
val fee = nodeFee(nodeParams.feeBase, nodeParams.feeProportionalMillionth, outgoingAmount)
assert(routeParams.maxFeePct === 0) // should be disabled
assert(routeParams.maxFeeBase === incomingAmount - outgoingAmount - fee) // we collect our fee and then use what remains for the rest of the route
assert(routeParams.routeMaxCltv === incomingSinglePart.add.cltvExpiry - outgoingExpiry - nodeParams.expiryDelta) // we apply our cltv delta
assert(routeParams.maxFeeBase === incomingAmount - outgoingAmount)
assert(routeParams.routeMaxCltv === incomingSinglePart.add.cltvExpiry - outgoingExpiry)
assert(routeParams.isRelay)
}

test("relay incoming multi-part payment") { f =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
))

val Success(route :: Nil) = findRoute(graph, a, d, amount, maxFee = 7 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = 400000)
val weightedPath = Graph.pathWeight(a, route2Edges(route), amount, 0, None)
val weightedPath = Graph.pathWeight(a, route2Edges(route), amount, 0, None, false)
assert(route2Ids(route) === 4 :: 5 :: 6 :: Nil)
assert(weightedPath.length === 3)
assert(weightedPath.cost === expectedCost)
Expand Down Expand Up @@ -695,7 +695,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
makeEdge(7L, c, f, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT))
))

val fourShortestPaths = Graph.yenKshortestPaths(g1, d, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 4, None, 0, noopBoundaries)
val fourShortestPaths = Graph.yenKshortestPaths(g1, d, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 4, None, 0, noopBoundaries, false)
assert(fourShortestPaths.size === 4)
assert(hops2Ids(fourShortestPaths(0).path.map(graphEdgeToHop)) === 2 :: 5 :: Nil) // D -> E -> F
assert(hops2Ids(fourShortestPaths(1).path.map(graphEdgeToHop)) === 1 :: 3 :: 5 :: Nil) // D -> A -> E -> F
Expand All @@ -704,7 +704,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {

// Update balance D -> A to evict the last path (balance too low)
val g2 = g1.addEdge(makeEdge(1L, d, a, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT + 3.msat)))
val threeShortestPaths = Graph.yenKshortestPaths(g2, d, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 4, None, 0, noopBoundaries)
val threeShortestPaths = Graph.yenKshortestPaths(g2, d, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 4, None, 0, noopBoundaries, false)
assert(threeShortestPaths.size === 3)
assert(hops2Ids(threeShortestPaths(0).path.map(graphEdgeToHop)) === 2 :: 5 :: Nil) // D -> E -> F
assert(hops2Ids(threeShortestPaths(1).path.map(graphEdgeToHop)) === 1 :: 3 :: 5 :: Nil) // D -> A -> E -> F
Expand Down Expand Up @@ -733,7 +733,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
makeEdge(90L, g, h, 2 msat, 0)
))

val twoShortestPaths = Graph.yenKshortestPaths(graph, c, h, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 2, None, 0, noopBoundaries)
val twoShortestPaths = Graph.yenKshortestPaths(graph, c, h, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 2, None, 0, noopBoundaries, false)

assert(twoShortestPaths.size === 2)
val shortest = twoShortestPaths(0)
Expand Down Expand Up @@ -764,7 +764,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
))

// we ask for 3 shortest paths but only 2 can be found
val foundPaths = Graph.yenKshortestPaths(graph, a, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 3, None, 0, noopBoundaries)
val foundPaths = Graph.yenKshortestPaths(graph, a, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 3, None, 0, noopBoundaries, false)
assert(foundPaths.size === 2)
assert(hops2Ids(foundPaths(0).path.map(graphEdgeToHop)) === 1 :: 2 :: 3 :: Nil) // A -> B -> C -> F
assert(hops2Ids(foundPaths(1).path.map(graphEdgeToHop)) === 1 :: 2 :: 4 :: 5 :: 6 :: Nil) // A -> B -> C -> D -> E -> F
Expand All @@ -791,7 +791,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
for (_ <- 0 to 10) {
val Success(routes) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, strictFee, numRoutes = 3, routeParams = strictFeeParams, currentBlockHeight = 400000)
assert(routes.length === 2, routes)
val weightedPath = Graph.pathWeight(a, route2Edges(routes.head), DEFAULT_AMOUNT_MSAT, 400000, None)
val weightedPath = Graph.pathWeight(a, route2Edges(routes.head), DEFAULT_AMOUNT_MSAT, 400000, None, false)
val totalFees = weightedPath.cost - DEFAULT_AMOUNT_MSAT
// over the three routes we could only get the 2 cheapest because the third is too expensive (over 7 msat of fees)
assert(totalFees === 5.msat || totalFees === 6.msat)
Expand Down Expand Up @@ -1662,6 +1662,14 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
val fees = routes.map(_.fee)
assert(fees.forall(_ == fees.head))
}

test("can't relay if fee is not sufficient") {
val g = DirectedGraph(List(
makeEdge(1L, a, b, 1000 msat, 7000),
))

val Failure(_) = findRoute(g, a, b, 10000000 msat, 10000 msat, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS.copy(isRelay = true), currentBlockHeight = 400000)
}
}

object RouteCalculationSpec {
Expand All @@ -1672,7 +1680,7 @@ object RouteCalculationSpec {
val DEFAULT_MAX_FEE = 100000 msat
val DEFAULT_CAPACITY = 100000 sat

val DEFAULT_ROUTE_PARAMS = RouteParams(randomize = false, 21000 msat, 0.03, 6, CltvExpiryDelta(2016), None, MultiPartParams(1000 msat, 10))
val DEFAULT_ROUTE_PARAMS = RouteParams(randomize = false, 21000 msat, 0.03, 6, CltvExpiryDelta(2016), None, MultiPartParams(1000 msat, 10), false)

val DUMMY_SIG = Transactions.PlaceHolderSig

Expand Down

0 comments on commit 42dd0db

Please sign in to comment.