diff --git a/lnd_test.go b/lnd_test.go index 6c50f7961..240d99ce9 100644 --- a/lnd_test.go +++ b/lnd_test.go @@ -41,6 +41,10 @@ var ( harnessNetParams = &chaincfg.SimNetParams ) +const ( + testFeeBase = 1e+6 +) + // harnessTest wraps a regular testing.T providing enhanced error detection // and propagation. All error will be augmented with a full stack-trace in // order to aid in debugging. Additionally, any panics caused by active @@ -876,14 +880,13 @@ func testUpdateChannelPolicy(net *lntest.NetworkHarness, t *harnessTest) { // With our little cluster set up, we'll update the fees for the // channel Bob side of the Alice->Bob channel, and make sure all nodes // learn about it. - const feeBase = 1000000 baseFee := int64(1500) feeRate := int64(12) timeLockDelta := uint32(66) expectedPolicy := &lnrpc.RoutingPolicy{ FeeBaseMsat: baseFee, - FeeRateMilliMsat: feeBase * feeRate, + FeeRateMilliMsat: testFeeBase * feeRate, TimeLockDelta: timeLockDelta, } @@ -971,7 +974,7 @@ func testUpdateChannelPolicy(net *lntest.NetworkHarness, t *harnessTest) { timeLockDelta = uint32(22) expectedPolicy.FeeBaseMsat = baseFee - expectedPolicy.FeeRateMilliMsat = feeBase * feeRate + expectedPolicy.FeeRateMilliMsat = testFeeBase * feeRate expectedPolicy.TimeLockDelta = timeLockDelta req = &lnrpc.PolicyUpdateRequest{ @@ -2773,6 +2776,48 @@ func assertAmountPaid(t *harnessTest, ctxb context.Context, channelName string, } } +// updateChannelPolicy updates the channel policy of node to the +// given fees and timelock delta. This function blocks until +// listenerNode has received the policy update. +func updateChannelPolicy(t *harnessTest, node *lntest.HarnessNode, + chanPoint *lnrpc.ChannelPoint, baseFee int64, feeRate int64, + timeLockDelta uint32, listenerNode *lntest.HarnessNode) { + + ctxb := context.Background() + timeout := time.Duration(time.Second * 15) + + expectedPolicy := &lnrpc.RoutingPolicy{ + FeeBaseMsat: baseFee, + FeeRateMilliMsat: feeRate, + TimeLockDelta: timeLockDelta, + } + + updateFeeReq := &lnrpc.PolicyUpdateRequest{ + BaseFeeMsat: baseFee, + FeeRate: float64(feeRate) / testFeeBase, + TimeLockDelta: timeLockDelta, + Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{ + ChanPoint: chanPoint, + }, + } + + ctxt, _ := context.WithTimeout(ctxb, timeout) + if _, err := node.UpdateChannelPolicy(ctxt, updateFeeReq); err != nil { + t.Fatalf("unable to update chan policy: %v", err) + } + + // Wait for listener node to receive the channel update from node. + ctxt, _ = context.WithTimeout(ctxb, timeout) + listenerUpdates, aQuit := subscribeGraphNotifications(t, ctxt, + listenerNode) + defer close(aQuit) + + waitForChannelUpdate( + t, listenerUpdates, node.PubKeyStr, expectedPolicy, + chanPoint, + ) +} + func testMultiHopPayments(net *lntest.NetworkHarness, t *harnessTest) { const chanAmt = btcutil.Amount(100000) ctxb := context.Background() @@ -2934,6 +2979,15 @@ func testMultiHopPayments(net *lntest.NetworkHarness, t *harnessTest) { time.Sleep(time.Millisecond * 50) + // Set the fee policies of the Alice -> Bob and the Dave -> Alice + // channel edges to relatively large non default values. This makes it + // possible to pick up more subtle fee calculation errors. + updateChannelPolicy(t, net.Alice, chanPointAlice, 1000, 100000, + 144, carol) + + updateChannelPolicy(t, dave, chanPointDave, 5000, 150000, + 144, carol) + // Using Carol as the source, pay to the 5 invoices from Bob created // above. ctxt, _ = context.WithTimeout(ctxb, timeout) @@ -2953,42 +3007,61 @@ func testMultiHopPayments(net *lntest.NetworkHarness, t *harnessTest) { // increasing of time is needed to embed the HTLC in commitment // transaction, in channel Carol->David->Alice->Bob, order is Bob, // Alice, David, Carol. - const amountPaid = int64(5000) + + // The final node bob expects to get paid five times 1000 sat. + expectedAmountPaidAtoB := int64(5 * 1000) + assertAmountPaid(t, ctxb, "Alice(local) => Bob(remote)", net.Bob, - aliceFundPoint, int64(0), amountPaid) + aliceFundPoint, int64(0), expectedAmountPaidAtoB) assertAmountPaid(t, ctxb, "Alice(local) => Bob(remote)", net.Alice, - aliceFundPoint, amountPaid, int64(0)) + aliceFundPoint, expectedAmountPaidAtoB, int64(0)) + + // To forward a payment of 1000 sat, Alice is charging a fee of + // 1 sat + 10% = 101 sat. + const expectedFeeAlice = 5 * 101 + + // Dave needs to pay what Alice pays plus Alice's fee. + expectedAmountPaidDtoA := expectedAmountPaidAtoB + expectedFeeAlice + assertAmountPaid(t, ctxb, "Dave(local) => Alice(remote)", net.Alice, - daveFundPoint, int64(0), amountPaid+(baseFee*numPayments)) + daveFundPoint, int64(0), expectedAmountPaidDtoA) assertAmountPaid(t, ctxb, "Dave(local) => Alice(remote)", dave, - daveFundPoint, amountPaid+(baseFee*numPayments), int64(0)) + daveFundPoint, expectedAmountPaidDtoA, int64(0)) + + // To forward a payment of 1101 sat, Dave is charging a fee of + // 5 sat + 15% = 170.15 sat. This is rounded down in rpcserver to 170. + const expectedFeeDave = 5 * 170 + + // Carol needs to pay what Dave pays plus Dave's fee. + expectedAmountPaidCtoD := expectedAmountPaidDtoA + expectedFeeDave + assertAmountPaid(t, ctxb, "Carol(local) => Dave(remote)", dave, - carolFundPoint, int64(0), amountPaid+((baseFee*numPayments)*2)) + carolFundPoint, int64(0), expectedAmountPaidCtoD) assertAmountPaid(t, ctxb, "Carol(local) => Dave(remote)", carol, - carolFundPoint, amountPaid+(baseFee*numPayments)*2, int64(0)) + carolFundPoint, expectedAmountPaidCtoD, int64(0)) // Now that we know all the balances have been settled out properly, // we'll ensure that our internal record keeping for completed circuits // was properly updated. // First, check that the FeeReport response shows the proper fees - // accrued over each time range. Dave should've earned 1 satoshi for + // accrued over each time range. Dave should've earned 170 satoshi for // each of the forwarded payments. feeReport, err := dave.FeeReport(ctxb, &lnrpc.FeeReportRequest{}) if err != nil { t.Fatalf("unable to query for fee report: %v", err) } - const exectedFees = 5 - if feeReport.DayFeeSum != exectedFees { - t.Fatalf("fee mismatch: expected %v, got %v", 5, + + if feeReport.DayFeeSum != uint64(expectedFeeDave) { + t.Fatalf("fee mismatch: expected %v, got %v", expectedFeeDave, feeReport.DayFeeSum) } - if feeReport.WeekFeeSum != exectedFees { - t.Fatalf("fee mismatch: expected %v, got %v", 5, + if feeReport.WeekFeeSum != uint64(expectedFeeDave) { + t.Fatalf("fee mismatch: expected %v, got %v", expectedFeeDave, feeReport.WeekFeeSum) } - if feeReport.MonthFeeSum != exectedFees { - t.Fatalf("fee mismatch: expected %v, got %v", 5, + if feeReport.MonthFeeSum != uint64(expectedFeeDave) { + t.Fatalf("fee mismatch: expected %v, got %v", expectedFeeDave, feeReport.MonthFeeSum) } @@ -3004,11 +3077,12 @@ func testMultiHopPayments(net *lntest.NetworkHarness, t *harnessTest) { t.Fatalf("wrong number of forwarding event: expected %v, "+ "got %v", 5, len(fwdingHistory.ForwardingEvents)) } + expectedForwardingFee := uint64(expectedFeeDave / numPayments) for _, event := range fwdingHistory.ForwardingEvents { - // Each event should show a fee of 1 satoshi. - if event.Fee != 1 { - t.Fatalf("fee mismatch: expected %v, got %v", 1, - event.Fee) + // Each event should show a fee of 170 satoshi. + if event.Fee != expectedForwardingFee { + t.Fatalf("fee mismatch: expected %v, got %v", + expectedForwardingFee, event.Fee) } } @@ -9775,14 +9849,13 @@ func testRouteFeeCutoff(net *lntest.NetworkHarness, t *harnessTest) { // Therefore, we'll update the fee policy on Carol's side for the // channel between her and Dave to invalidate the route: // Alice -> Carol -> Dave - const feeBase = 1e+6 baseFee := int64(10000) feeRate := int64(5) timeLockDelta := uint32(144) expectedPolicy := &lnrpc.RoutingPolicy{ FeeBaseMsat: baseFee, - FeeRateMilliMsat: feeBase * feeRate, + FeeRateMilliMsat: testFeeBase * feeRate, TimeLockDelta: timeLockDelta, } diff --git a/routing/pathfind.go b/routing/pathfind.go index 5aa2298b4..6e8026f3a 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -50,10 +50,11 @@ type HopHint struct { CLTVExpiryDelta uint16 } -// ChannelHop is an intermediate hop within the network with a greater -// multi-hop payment route. This struct contains the relevant routing policy of -// the particular edge, as well as the total capacity, and origin chain of the -// channel itself. +// ChannelHop describes the channel through which an intermediate or final +// hop can be reached. This struct contains the relevant routing policy of +// the particular edge (which is a property of the source node of the channel +// edge), as well as the total capacity. It also includes the origin chain of +// the channel itself. type ChannelHop struct { // Capacity is the total capacity of the channel being traversed. This // value is expressed for stability in satoshis. @@ -69,13 +70,15 @@ type ChannelHop struct { *channeldb.ChannelEdgePolicy } -// Hop represents the forwarding details at a particular position within the -// final route. This struct houses the values necessary to create the HTLC -// which will travel along this hop, and also encode the per-hop payload -// included within the Sphinx packet. +// Hop represents an intermediate or final node of the route. This naming +// is in line with the definition given in BOLT #4: Onion Routing Protocol. +// The struct houses the channel along which this hop can be reached and +// the values necessary to create the HTLC that needs to be sent to the +// next hop. It is also used to encode the per-hop payload included within +// the Sphinx packet. type Hop struct { - // Channel is the active payment channel edge that this hop will travel - // along. + // Channel is the active payment channel edge along which the packet + // travels to reach this hop. This is the _incoming_ channel to this hop. Channel *ChannelHop // OutgoingTimeLock is the timelock value that should be used when @@ -269,11 +272,6 @@ func newRoute(amtToSend, feeLimit lnwire.MilliSatoshi, sourceVertex Vertex, // information for the first hop so the mapping is sound. route.nextHopMap[sourceVertex] = pathEdges[0] - // The running amount is the total amount of satoshis required at this - // point in the route. We start this value at the amount we want to - // send to the destination. This value will then get successively - // larger as we compute the fees going backwards. - runningAmt := amtToSend pathLength := len(pathEdges) for i := pathLength - 1; i >= 0; i-- { edge := pathEdges[i] @@ -285,59 +283,56 @@ func newRoute(amtToSend, feeLimit lnwire.MilliSatoshi, sourceVertex Vertex, route.nodeIndex[v] = struct{}{} route.chanIndex[edge.ChannelID] = struct{}{} - // If this isn't a direct payment, and this isn't the last hop - // in the route, then we'll also populate the nextHop map to - // allow easy route traversal by callers. + // If this isn't a direct payment, and this isn't the edge to + // the last hop in the route, then we'll also populate the + // nextHop map to allow easy route traversal by callers. if len(pathEdges) > 1 && i != len(pathEdges)-1 { route.nextHopMap[v] = route.Hops[i+1].Channel } // Now we'll start to calculate the items within the per-hop - // payload for this current hop. - // - // If this is the last hop, then we send the exact amount and - // pay no fee, as we're paying directly to the receiver, and - // there're no additional hops. - amtToForward := runningAmt + // payload for the hop this edge is leading to. This hop will + // be called the 'current hop'. + + // If it is the last hop, then the hop payload will contain + // the exact amount. In BOLT #4: Onion Routing + // Protocol / "Payload for the Last Node", this is detailed. + amtToForward := amtToSend + + // Fee is not part of the hop payload, but only used for + // reporting through RPC. Set to zero for the final hop. fee := lnwire.MilliSatoshi(0) - // If this isn't the last hop, to add enough funds to pay for - // transit over the next link. + // If the current hop isn't the last hop, then add enough funds + // to pay for transit over the next link. if i != len(pathEdges)-1 { - // We'll grab the edge policy and per-hop payload of - // the prior hop so we can calculate fees properly. - prevEdge := pathEdges[i+1] - prevHop := route.Hops[i+1] + // We'll grab the per-hop payload of the next hop (the + // hop _after_ the hop this edge leads to) in the + // route so we can calculate fees properly. + nextHop := route.Hops[i+1] - // The fee for this hop, will be based on how much the - // prior hop carried, as we'll need to increase the - // amount of satoshis incoming into this hop to - // properly pay the required fees. - prevAmount := prevHop.AmtToForward - fee = computeFee(prevAmount, prevEdge.ChannelEdgePolicy) + // The amount that the current hop needs to forward is + // based on how much the next hop forwards plus the fee + // that needs to be paid to the next hop. + amtToForward = nextHop.AmtToForward + nextHop.Fee - // With the fee computed, we increment the total amount - // as we need to pay this fee. This value represents - // the amount of funds that will come _into_ this edge. - runningAmt += fee - - // Otherwise, for a node to forward an HTLC, then - // following inequality most hold true: - // - // * amt_in - fee >= amt_to_forward - amtToForward = runningAmt - fee + // The fee that needs to be paid to the current hop is + // based on the amount that this hop needs to forward + // and its policy for the outgoing channel. This policy + // is stored as part of the incoming channel of + // the next hop. + fee = computeFee(amtToForward, nextHop.Channel.ChannelEdgePolicy) } - // Now we create the hop struct for this point in the route. - // The amount to forward is the running amount, and we compute - // the required fee based on this amount. - nextHop := &Hop{ + // Now we create the hop struct for the current hop. + currentHop := &Hop{ Channel: edge, AmtToForward: amtToForward, Fee: fee, } - route.TotalFees += nextHop.Fee + // Accumulate all fees. + route.TotalFees += currentHop.Fee // Invalidate this route if its total fees exceed our fee limit. if route.TotalFees > feeLimit { @@ -346,14 +341,17 @@ func newRoute(amtToSend, feeLimit lnwire.MilliSatoshi, sourceVertex Vertex, return nil, newErrf(ErrFeeLimitExceeded, err) } - // As a sanity check, we ensure that the selected channel has - // enough capacity to forward the required amount which - // includes the fee dictated at each hop. - if nextHop.AmtToForward.ToSatoshis() > nextHop.Channel.Capacity { + // As a sanity check, we ensure that the incoming channel has + // enough capacity to carry the required amount which + // includes the fee dictated at each hop. Make the comparison + // in msat to prevent rounding errors. + if currentHop.AmtToForward + fee > lnwire.NewMSatFromSatoshis( + currentHop.Channel.Capacity) { + err := fmt.Sprintf("channel graph has insufficient "+ "capacity for the payment: need %v, have %v", - nextHop.AmtToForward.ToSatoshis(), - nextHop.Channel.Capacity) + currentHop.AmtToForward.ToSatoshis(), + currentHop.Channel.Capacity) return nil, newErrf(ErrInsufficientCapacity, err) } @@ -367,7 +365,7 @@ func newRoute(amtToSend, feeLimit lnwire.MilliSatoshi, sourceVertex Vertex, // last link in the route. route.TotalTimeLock += uint32(finalCLTVDelta) - nextHop.OutgoingTimeLock = currentHeight + uint32(finalCLTVDelta) + currentHop.OutgoingTimeLock = currentHeight + uint32(finalCLTVDelta) } else { // Next, increment the total timelock of the entire // route such that each hops time lock increases as we @@ -380,10 +378,10 @@ func newRoute(amtToSend, feeLimit lnwire.MilliSatoshi, sourceVertex Vertex, // be the value of the time-lock for the _outgoing_ // HTLC, so we factor in their specified grace period // (time lock delta). - nextHop.OutgoingTimeLock = route.TotalTimeLock - delta + currentHop.OutgoingTimeLock = route.TotalTimeLock - delta } - route.Hops[i] = nextHop + route.Hops[i] = currentHop } // We'll then make a second run through our route in order to set up @@ -393,9 +391,11 @@ func newRoute(amtToSend, feeLimit lnwire.MilliSatoshi, sourceVertex Vertex, route.prevHopMap[vertex] = hop.Channel } - // The total amount required for this route will be the value the - // source extends to the first hop in the route. - route.TotalAmount = runningAmt + // The total amount required for this route will be the value + // that the first hop needs to forward plus the fee that + // the first hop charges for this. Note that the sender of the + // payment is not a hop in the route. + route.TotalAmount = route.Hops[0].AmtToForward + route.Hops[0].Fee return route, nil } diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index 7a7be0da2..52177c593 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -616,6 +616,236 @@ func TestKShortestPathFinding(t *testing.T) { assertExpectedPath(t, paths[1], "roasbeef", "satoshi", "luoji") } +// TestNewRoute tests whether the construction of hop payloads by newRoute +// is executed correctly. +func TestNewRoute(t *testing.T) { + + var sourceKey [33]byte + sourceVertex := Vertex(sourceKey) + + const ( + startingHeight = 100 + finalHopCLTV = 1 + ) + + createHop := func(baseFee lnwire.MilliSatoshi, + feeRate lnwire.MilliSatoshi, + capacity btcutil.Amount, + timeLockDelta uint16) (*ChannelHop) { + + return &ChannelHop { + ChannelEdgePolicy: &channeldb.ChannelEdgePolicy { + Node: &channeldb.LightningNode{}, + FeeProportionalMillionths: feeRate, + FeeBaseMSat: baseFee, + TimeLockDelta: timeLockDelta, + }, + Capacity: capacity, + } + } + + testCases := []struct { + // name identifies the test case in the test output. + name string + + // hops is the list of hops (the route) that gets passed into + // the call to newRoute. + hops []*ChannelHop + + // paymentAmount is the amount that is send into the route + // indicated by hops. + paymentAmount lnwire.MilliSatoshi + + // expectedFees is a list of fees that every hop is expected + // to charge for forwarding. + expectedFees []lnwire.MilliSatoshi + + // expectedTimeLocks is a list of time lock values that every + // hop is expected to specify in its outgoing HTLC. The time + // lock values in this list are relative to the current block + // height. + expectedTimeLocks []uint32 + + // expectedTotalAmount is the total amount that is expected to + // be returned from newRoute. This amount should include all + // the fees to be paid to intermediate hops. + expectedTotalAmount lnwire.MilliSatoshi + + // expectedTotalTimeLock is the time lock that is expected to + // be returned from newRoute. This is the time lock that should + // be specified in the HTLC that is sent by the source node. + // expectedTotalTimeLock is relative to the current block height. + expectedTotalTimeLock uint32 + + // expectError indicates whether the newRoute call is expected + // to fail or succeed. + expectError bool + + // expectedErrorCode indicates the expected error code when + // expectError is true. + expectedErrorCode errorCode + } { + { + // For a single hop payment, no fees are expected to be paid. + name: "single hop", + paymentAmount: 100000, + hops: []*ChannelHop { + createHop(100, 1000, 1000, 10), + }, + expectedFees: []lnwire.MilliSatoshi {0}, + expectedTimeLocks: []uint32 {1}, + expectedTotalAmount: 100000, + expectedTotalTimeLock: 1, + }, { + // For a two hop payment, only the fee for the first hop + // needs to be paid. The destination hop does not require + // a fee to receive the payment. + name: "two hop", + paymentAmount: 100000, + hops: []*ChannelHop { + createHop(0, 1000, 1000, 10), + createHop(30, 1000, 1000, 5), + }, + expectedFees: []lnwire.MilliSatoshi {130, 0}, + expectedTimeLocks: []uint32 {1, 1}, + expectedTotalAmount: 100130, + expectedTotalTimeLock: 6, + }, { + // Insufficient capacity in first channel when fees are added. + name: "two hop insufficient", + paymentAmount: 100000, + hops: []*ChannelHop { + createHop(0, 1000, 100, 10), + createHop(0, 1000, 1000, 5), + }, + expectError: true, + expectedErrorCode: ErrInsufficientCapacity, + }, { + // A three hop payment where the first and second hop + // will both charge 1 msat. The fee for the first hop + // is actually slightly higher than 1, because the amount + // to forward also includes the fee for the second hop. This + // gets rounded down to 1. + name: "three hop", + paymentAmount: 100000, + hops: []*ChannelHop { + createHop(0, 10, 1000, 10), + createHop(0, 10, 1000, 5), + createHop(0, 10, 1000, 3), + }, + expectedFees: []lnwire.MilliSatoshi {1, 1, 0}, + expectedTotalAmount: 100002, + expectedTimeLocks: []uint32 {4, 1, 1}, + expectedTotalTimeLock: 9, + }, { + // A three hop payment where the fee of the first hop + // is slightly higher (11) than the fee at the second hop, + // because of the increase amount to forward. + name: "three hop with fee carry over", + paymentAmount: 100000, + hops: []*ChannelHop { + createHop(0, 10000, 1000, 10), + createHop(0, 10000, 1000, 5), + createHop(0, 10000, 1000, 3), + }, + expectedFees: []lnwire.MilliSatoshi {1010, 1000, 0}, + expectedTotalAmount: 102010, + expectedTimeLocks: []uint32 {4, 1, 1}, + expectedTotalTimeLock: 9, + }, { + // A three hop payment where the fee policies of the first and + // second hop are just high enough to show the fee carry over + // effect. + name: "three hop with minimal fees for carry over", + paymentAmount: 100000, + hops: []*ChannelHop { + createHop(0, 10000, 1000, 10), + + // First hop charges 0.1% so the second hop fee + // should show up in the first hop fee as 1 msat + // extra. + createHop(0, 1000, 1000, 5), + + // Second hop charges a fixed 1000 msat. + createHop(1000, 0, 1000, 3), + }, + expectedFees: []lnwire.MilliSatoshi {101, 1000, 0}, + expectedTotalAmount: 101101, + expectedTimeLocks: []uint32 {4, 1, 1}, + expectedTotalTimeLock: 9, + } } + + for _, testCase := range testCases { + assertRoute := func(t *testing.T, route *Route) { + if route.TotalAmount != testCase.expectedTotalAmount { + t.Errorf("Expected total amount is be %v" + + ", but got %v instead", + testCase.expectedTotalAmount, + route.TotalAmount) + } + + for i := 0; i < len(testCase.expectedFees); i++ { + if testCase.expectedFees[i] != + route.Hops[i].Fee { + + t.Errorf("Expected fee for hop %v to " + + "be %v, but got %v instead", + i, testCase.expectedFees[i], + route.Hops[i].Fee) + } + } + + expectedTimeLockHeight := startingHeight + + testCase.expectedTotalTimeLock + + if route.TotalTimeLock != expectedTimeLockHeight { + + t.Errorf("Expected total time lock to be %v" + + ", but got %v instead", + expectedTimeLockHeight, + route.TotalTimeLock) + } + + for i := 0; i < len(testCase.expectedTimeLocks); i++ { + expectedTimeLockHeight := startingHeight + + testCase.expectedTimeLocks[i] + + if expectedTimeLockHeight != + route.Hops[i].OutgoingTimeLock { + + t.Errorf("Expected time lock for hop " + + "%v to be %v, but got %v instead", + i, expectedTimeLockHeight, + route.Hops[i].OutgoingTimeLock) + } + } + } + + t.Run(testCase.name, func(t *testing.T) { + route, err := newRoute(testCase.paymentAmount, + noFeeLimit, + sourceVertex, testCase.hops, startingHeight, + finalHopCLTV) + + if testCase.expectError { + expectedCode := testCase.expectedErrorCode + if err == nil || !IsError(err, expectedCode) { + t.Errorf("expected newRoute to fail " + + "with error code %v, but got" + + "%v instead", + expectedCode, err) + } + } else { + if err != nil { + t.Errorf("unable to create path: %v", err) + } + + assertRoute(t, route) + } + }) + } +} + func TestNewRoutePathTooLong(t *testing.T) { t.Skip()