routing: extend path finding to be TLV-EOB aware, allow dest TLV records

In this commit, we extend the path finding to be able to recognize when
a node needs the new TLV format, or the legacy format based on the
feature bits they expose. We also extend the `LightningPayment` struct
to allow the caller to specify an arbitrary set of TLV records which can
be used for a number of use-cases including various variants of
spontaneous payments.
This commit is contained in:
Olaoluwa Osuntokun 2019-07-30 21:41:58 -07:00
parent 5b4c8ac232
commit 4697cfde30
No known key found for this signature in database
GPG Key ID: CE58F7F8E20FD9A2
9 changed files with 158 additions and 50 deletions

@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/btcsuite/btcd/btcec" "github.com/btcsuite/btcd/btcec"
"github.com/davecgh/go-spew/spew"
"github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/routing/route"
@ -26,6 +27,7 @@ var (
ChannelID: 12345, ChannelID: 12345,
OutgoingTimeLock: 111, OutgoingTimeLock: 111,
AmtToForward: 555, AmtToForward: 555,
LegacyPayload: true,
} }
testRoute = route.Route{ testRoute = route.Route{
@ -144,7 +146,9 @@ func TestControlTowerSubscribeSuccess(t *testing.T) {
} }
if !reflect.DeepEqual(result.Route, &attempt.Route) { if !reflect.DeepEqual(result.Route, &attempt.Route) {
t.Fatal("unexpected route") t.Fatalf("unexpected route: %v vs %v",
spew.Sdump(result.Route),
spew.Sdump(attempt.Route))
} }
// After the final event, we expect the channel to be closed. // After the final event, we expect the channel to be closed.

@ -7,6 +7,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/davecgh/go-spew/spew"
"github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/lnwire"
"github.com/coreos/bbolt" "github.com/coreos/bbolt"
@ -50,7 +51,8 @@ func TestMissionControlStore(t *testing.T) {
SourcePubKey: route.Vertex{1}, SourcePubKey: route.Vertex{1},
Hops: []*route.Hop{ Hops: []*route.Hop{
{ {
PubKeyBytes: route.Vertex{2}, PubKeyBytes: route.Vertex{2},
LegacyPayload: true,
}, },
}, },
} }
@ -99,10 +101,12 @@ func TestMissionControlStore(t *testing.T) {
// Check that results are stored in chronological order. // Check that results are stored in chronological order.
if !reflect.DeepEqual(&result1, results[0]) { if !reflect.DeepEqual(&result1, results[0]) {
t.Fatal() t.Fatalf("the results differ: %v vs %v", spew.Sdump(&result1),
spew.Sdump(results[0]))
} }
if !reflect.DeepEqual(&result2, results[1]) { if !reflect.DeepEqual(&result2, results[1]) {
t.Fatal() t.Fatalf("the results differ: %v vs %v", spew.Sdump(&result2),
spew.Sdump(results[1]))
} }
// Recreate store to test pruning. // Recreate store to test pruning.
@ -132,9 +136,11 @@ func TestMissionControlStore(t *testing.T) {
} }
if !reflect.DeepEqual(&result2, results[0]) { if !reflect.DeepEqual(&result2, results[0]) {
t.Fatal() t.Fatalf("the results differ: %v vs %v", spew.Sdump(&result2),
spew.Sdump(results[0]))
} }
if !reflect.DeepEqual(&result3, results[1]) { if !reflect.DeepEqual(&result3, results[1]) {
t.Fatal() t.Fatalf("the results differ: %v vs %v", spew.Sdump(&result3),
spew.Sdump(results[1]))
} }
} }

@ -16,13 +16,15 @@ var (
SourcePubKey: route.Vertex{10}, SourcePubKey: route.Vertex{10},
Hops: []*route.Hop{ Hops: []*route.Hop{
{ {
ChannelID: 1, ChannelID: 1,
PubKeyBytes: route.Vertex{11}, PubKeyBytes: route.Vertex{11},
AmtToForward: 1000, AmtToForward: 1000,
LegacyPayload: true,
}, },
{ {
ChannelID: 2, ChannelID: 2,
PubKeyBytes: route.Vertex{12}, PubKeyBytes: route.Vertex{12},
LegacyPayload: true,
}, },
}, },
} }
@ -167,7 +169,8 @@ func TestMissionControl(t *testing.T) {
// Check whether history snapshot looks sane. // Check whether history snapshot looks sane.
history := ctx.mc.GetHistorySnapshot() history := ctx.mc.GetHistorySnapshot()
if len(history.Nodes) != 1 { if len(history.Nodes) != 1 {
t.Fatal("unexpected number of nodes") t.Fatalf("unexpected number of nodes: expected 1 got %v",
len(history.Nodes))
} }
if len(history.Pairs) != 1 { if len(history.Pairs) != 1 {

@ -2,6 +2,7 @@ package routing
import ( import (
"container/heap" "container/heap"
"fmt"
"math" "math"
"github.com/coreos/bbolt" "github.com/coreos/bbolt"
@ -9,6 +10,7 @@ import (
"github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/tlv"
) )
const ( const (
@ -98,7 +100,8 @@ func isSamePath(path1, path2 []*channeldb.ChannelEdgePolicy) bool {
// the source to the target node of the path finding attempt. // the source to the target node of the path finding attempt.
func newRoute(amtToSend lnwire.MilliSatoshi, sourceVertex route.Vertex, func newRoute(amtToSend lnwire.MilliSatoshi, sourceVertex route.Vertex,
pathEdges []*channeldb.ChannelEdgePolicy, currentHeight uint32, pathEdges []*channeldb.ChannelEdgePolicy, currentHeight uint32,
finalCLTVDelta uint16) (*route.Route, error) { finalCLTVDelta uint16,
finalDestRecords []tlv.Record) (*route.Route, error) {
var ( var (
hops []*route.Hop hops []*route.Hop
@ -179,7 +182,27 @@ func newRoute(amtToSend lnwire.MilliSatoshi, sourceVertex route.Vertex,
ChannelID: edge.ChannelID, ChannelID: edge.ChannelID,
AmtToForward: amtToForward, AmtToForward: amtToForward,
OutgoingTimeLock: outgoingTimeLock, OutgoingTimeLock: outgoingTimeLock,
LegacyPayload: true,
} }
// We start out above by assuming that this node needs the
// legacy payload, as if we don't have the full
// NodeAnnouncement information for this node, then we can't
// assume it knows the latest features. If we do have a feature
// vector for this node, then we'll update the info now.
if edge.Node.Features != nil {
features := edge.Node.Features
currentHop.LegacyPayload = !features.HasFeature(
lnwire.TLVOnionPayloadOptional,
)
}
// If this is the last hop, then we'll populate any TLV records
// destined for it.
if i == len(pathEdges)-1 && len(finalDestRecords) != 0 {
currentHop.TLVRecords = finalDestRecords
}
hops = append([]*route.Hop{currentHop}, hops...) hops = append([]*route.Hop{currentHop}, hops...)
// Finally, we update the amount that needs to flow into the // Finally, we update the amount that needs to flow into the
@ -190,7 +213,8 @@ func newRoute(amtToSend lnwire.MilliSatoshi, sourceVertex route.Vertex,
// With the base routing data expressed as hops, build the full route // With the base routing data expressed as hops, build the full route
newRoute, err := route.NewRouteFromHops( newRoute, err := route.NewRouteFromHops(
nextIncomingAmount, totalTimeLock, route.Vertex(sourceVertex), hops, nextIncomingAmount, totalTimeLock, route.Vertex(sourceVertex),
hops,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -261,6 +285,11 @@ type RestrictParams struct {
// ctlv. After path finding is complete, the caller needs to increase // ctlv. After path finding is complete, the caller needs to increase
// all cltv expiry heights with the required final cltv delta. // all cltv expiry heights with the required final cltv delta.
CltvLimit *uint32 CltvLimit *uint32
// DestPayloadTLV should be set to true if we need to drop off a TLV
// payload at the final hop in order to properly complete this payment
// attempt.
DestPayloadTLV bool
} }
// PathFindingConfig defines global parameters that control the trade-off in // PathFindingConfig defines global parameters that control the trade-off in
@ -316,10 +345,34 @@ func findPath(g *graphParams, r *RestrictParams, cfg *PathFindingConfig,
node *channeldb.LightningNode) error { node *channeldb.LightningNode) error {
// TODO(roasbeef): with larger graph can just use disk seeks // TODO(roasbeef): with larger graph can just use disk seeks
// with a visited map // with a visited map
distance[route.Vertex(node.PubKeyBytes)] = nodeWithDist{ vertex := route.Vertex(node.PubKeyBytes)
distance[vertex] = nodeWithDist{
dist: infinity, dist: infinity,
node: route.Vertex(node.PubKeyBytes), node: route.Vertex(node.PubKeyBytes),
} }
// If we don't have any features for this node, then we can
// stop here.
if node.Features == nil || !r.DestPayloadTLV {
return nil
}
// We only need to perform this check for the final node, so we
// can exit here if this isn't them.
if vertex != target {
return nil
}
// If we have any records for the final hop, then we'll check
// not to ensure that they are actually able to interpret them.
supportsTLV := node.Features.HasFeature(
lnwire.TLVOnionPayloadOptional,
)
if !supportsTLV {
return fmt.Errorf("destination hop doesn't " +
"understand new TLV paylods")
}
return nil return nil
}); err != nil { }); err != nil {
return nil, err return nil, err

@ -670,7 +670,8 @@ func TestFindLowestFeePath(t *testing.T) {
} }
route, err := newRoute( route, err := newRoute(
paymentAmt, sourceVertex, path, startingHeight, paymentAmt, sourceVertex, path, startingHeight,
finalHopCLTV) finalHopCLTV, nil,
)
if err != nil { if err != nil {
t.Fatalf("unable to create path: %v", err) t.Fatalf("unable to create path: %v", err)
} }
@ -819,7 +820,7 @@ func testBasicGraphPathFindingCase(t *testing.T, graphInstance *testGraphInstanc
route, err := newRoute( route, err := newRoute(
paymentAmt, sourceVertex, path, startingHeight, paymentAmt, sourceVertex, path, startingHeight,
finalHopCLTV, finalHopCLTV, nil,
) )
if err != nil { if err != nil {
t.Fatalf("unable to create path: %v", err) t.Fatalf("unable to create path: %v", err)
@ -857,9 +858,15 @@ func testBasicGraphPathFindingCase(t *testing.T, graphInstance *testGraphInstanc
for i := 0; i < len(expectedHops)-1; i++ { for i := 0; i < len(expectedHops)-1; i++ {
var expectedHop [8]byte var expectedHop [8]byte
binary.BigEndian.PutUint64(expectedHop[:], route.Hops[i+1].ChannelID) binary.BigEndian.PutUint64(expectedHop[:], route.Hops[i+1].ChannelID)
if !bytes.Equal(sphinxPath[i].HopData.NextAddress[:], expectedHop[:]) {
hopData, err := sphinxPath[i].HopPayload.HopData()
if err != nil {
t.Fatalf("unable to make hop data: %v", err)
}
if !bytes.Equal(hopData.NextAddress[:], expectedHop[:]) {
t.Fatalf("first hop has incorrect next hop: expected %x, got %x", t.Fatalf("first hop has incorrect next hop: expected %x, got %x",
expectedHop[:], sphinxPath[i].HopData.NextAddress) expectedHop[:], hopData.NextAddress[:])
} }
} }
@ -867,9 +874,15 @@ func testBasicGraphPathFindingCase(t *testing.T, graphInstance *testGraphInstanc
// to indicate it's the exit hop. // to indicate it's the exit hop.
var exitHop [8]byte var exitHop [8]byte
lastHopIndex := len(expectedHops) - 1 lastHopIndex := len(expectedHops) - 1
if !bytes.Equal(sphinxPath[lastHopIndex].HopData.NextAddress[:], exitHop[:]) {
hopData, err := sphinxPath[lastHopIndex].HopPayload.HopData()
if err != nil {
t.Fatalf("unable to create hop data: %v", err)
}
if !bytes.Equal(hopData.NextAddress[:], exitHop[:]) {
t.Fatalf("first hop has incorrect next hop: expected %x, got %x", t.Fatalf("first hop has incorrect next hop: expected %x, got %x",
exitHop[:], sphinxPath[lastHopIndex].HopData.NextAddress) exitHop[:], hopData.NextAddress)
} }
var expectedTotalFee lnwire.MilliSatoshi var expectedTotalFee lnwire.MilliSatoshi
@ -1001,7 +1014,11 @@ func TestNewRoute(t *testing.T) {
timeLockDelta uint16) *channeldb.ChannelEdgePolicy { timeLockDelta uint16) *channeldb.ChannelEdgePolicy {
return &channeldb.ChannelEdgePolicy{ return &channeldb.ChannelEdgePolicy{
Node: &channeldb.LightningNode{}, Node: &channeldb.LightningNode{
Features: lnwire.NewFeatureVector(
nil, nil,
),
},
FeeProportionalMillionths: feeRate, FeeProportionalMillionths: feeRate,
FeeBaseMSat: baseFee, FeeBaseMSat: baseFee,
TimeLockDelta: timeLockDelta, TimeLockDelta: timeLockDelta,
@ -1176,9 +1193,11 @@ func TestNewRoute(t *testing.T) {
} }
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
route, err := newRoute(testCase.paymentAmount, route, err := newRoute(
sourceVertex, testCase.hops, startingHeight, testCase.paymentAmount, sourceVertex,
finalHopCLTV) testCase.hops, startingHeight, finalHopCLTV,
nil,
)
if testCase.expectError { if testCase.expectError {
expectedCode := testCase.expectedErrorCode expectedCode := testCase.expectedErrorCode
@ -1683,7 +1702,7 @@ func TestPathFindSpecExample(t *testing.T) {
carol := ctx.aliases["C"] carol := ctx.aliases["C"]
const amt lnwire.MilliSatoshi = 4999999 const amt lnwire.MilliSatoshi = 4999999
route, err := ctx.router.FindRoute( route, err := ctx.router.FindRoute(
bobNode.PubKeyBytes, carol, amt, noRestrictions, bobNode.PubKeyBytes, carol, amt, noRestrictions, nil,
) )
if err != nil { if err != nil {
t.Fatalf("unable to find route: %v", err) t.Fatalf("unable to find route: %v", err)
@ -1742,7 +1761,7 @@ func TestPathFindSpecExample(t *testing.T) {
// We'll now request a route from A -> B -> C. // We'll now request a route from A -> B -> C.
route, err = ctx.router.FindRoute( route, err = ctx.router.FindRoute(
source.PubKeyBytes, carol, amt, noRestrictions, source.PubKeyBytes, carol, amt, noRestrictions, nil,
) )
if err != nil { if err != nil {
t.Fatalf("unable to find routes: %v", err) t.Fatalf("unable to find routes: %v", err)
@ -1925,7 +1944,7 @@ func TestRestrictOutgoingChannel(t *testing.T) {
} }
route, err := newRoute( route, err := newRoute(
paymentAmt, sourceVertex, path, startingHeight, paymentAmt, sourceVertex, path, startingHeight,
finalHopCLTV, finalHopCLTV, nil,
) )
if err != nil { if err != nil {
t.Fatalf("unable to create path: %v", err) t.Fatalf("unable to create path: %v", err)
@ -2033,6 +2052,7 @@ func testCltvLimit(t *testing.T, limit uint32, expectedChannel uint64) {
) )
route, err := newRoute( route, err := newRoute(
paymentAmt, sourceVertex, path, startingHeight, finalHopCLTV, paymentAmt, sourceVertex, path, startingHeight, finalHopCLTV,
nil,
) )
if err != nil { if err != nil {
t.Fatalf("unable to create path: %v", err) t.Fatalf("unable to create path: %v", err)

@ -127,6 +127,7 @@ func (p *paymentSession) RequestRoute(payment *LightningPayment,
sourceVertex := route.Vertex(ss.SelfNode.PubKeyBytes) sourceVertex := route.Vertex(ss.SelfNode.PubKeyBytes)
route, err := newRoute( route, err := newRoute(
payment.Amount, sourceVertex, path, height, finalCltvDelta, payment.Amount, sourceVertex, path, height, finalCltvDelta,
payment.FinalDestRecords,
) )
if err != nil { if err != nil {
// TODO(roasbeef): return which edge/vertex didn't work // TODO(roasbeef): return which edge/vertex didn't work

@ -26,7 +26,11 @@ func TestRequestRoute(t *testing.T) {
path := []*channeldb.ChannelEdgePolicy{ path := []*channeldb.ChannelEdgePolicy{
{ {
Node: &channeldb.LightningNode{}, Node: &channeldb.LightningNode{
Features: lnwire.NewFeatureVector(
nil, nil,
),
},
}, },
} }

@ -26,6 +26,7 @@ import (
"github.com/lightningnetwork/lnd/routing/chainview" "github.com/lightningnetwork/lnd/routing/chainview"
"github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/ticker" "github.com/lightningnetwork/lnd/ticker"
"github.com/lightningnetwork/lnd/tlv"
"github.com/lightningnetwork/lnd/zpay32" "github.com/lightningnetwork/lnd/zpay32"
) )
@ -1429,6 +1430,7 @@ type routingMsg struct {
// factoring in channel capacities and cumulative fees along the route. // factoring in channel capacities and cumulative fees along the route.
func (r *ChannelRouter) FindRoute(source, target route.Vertex, func (r *ChannelRouter) FindRoute(source, target route.Vertex,
amt lnwire.MilliSatoshi, restrictions *RestrictParams, amt lnwire.MilliSatoshi, restrictions *RestrictParams,
destTlvRecords []tlv.Record,
finalExpiry ...uint16) (*route.Route, error) { finalExpiry ...uint16) (*route.Route, error) {
var finalCLTVDelta uint16 var finalCLTVDelta uint16
@ -1482,6 +1484,7 @@ func (r *ChannelRouter) FindRoute(source, target route.Vertex,
// Create the route with absolute time lock values. // Create the route with absolute time lock values.
route, err := newRoute( route, err := newRoute(
amt, source, path, uint32(currentHeight), finalCLTVDelta, amt, source, path, uint32(currentHeight), finalCLTVDelta,
destTlvRecords,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -1630,7 +1633,11 @@ type LightningPayment struct {
// attempting to complete. // attempting to complete.
PaymentRequest []byte PaymentRequest []byte
// TODO(roasbeef): add e2e message? // FinalDestRecords are TLV records that are to be sent to the final
// hop in the new onion payload format. If the destination does not
// understand this new onion payload format, then the payment will
// fail.
FinalDestRecords []tlv.Record
} }
// SendPayment attempts to send a payment as described within the passed // SendPayment attempts to send a payment as described within the passed
@ -1694,6 +1701,8 @@ func (r *ChannelRouter) preparePayment(payment *LightningPayment) (
// Record this payment hash with the ControlTower, ensuring it is not // Record this payment hash with the ControlTower, ensuring it is not
// already in-flight. // already in-flight.
//
// TODO(roasbeef): store records as part of creation info?
info := &channeldb.PaymentCreationInfo{ info := &channeldb.PaymentCreationInfo{
PaymentHash: payment.PaymentHash, PaymentHash: payment.PaymentHash,
Value: payment.Amount, Value: payment.Amount,

@ -231,7 +231,7 @@ func TestFindRoutesWithFeeLimit(t *testing.T) {
route, err := ctx.router.FindRoute( route, err := ctx.router.FindRoute(
ctx.router.selfNode.PubKeyBytes, ctx.router.selfNode.PubKeyBytes,
target, paymentAmt, restrictions, target, paymentAmt, restrictions, nil,
zpay32.DefaultFinalCLTVDelta, zpay32.DefaultFinalCLTVDelta,
) )
if err != nil { if err != nil {
@ -390,12 +390,14 @@ func TestChannelUpdateValidation(t *testing.T) {
hops := []*route.Hop{ hops := []*route.Hop{
{ {
ChannelID: 1, ChannelID: 1,
PubKeyBytes: hop1, PubKeyBytes: hop1,
LegacyPayload: true,
}, },
{ {
ChannelID: 2, ChannelID: 2,
PubKeyBytes: hop2, PubKeyBytes: hop2,
LegacyPayload: true,
}, },
} }
@ -1074,8 +1076,9 @@ func TestAddEdgeUnknownVertexes(t *testing.T) {
t.Parallel() t.Parallel()
const startingBlockHeight = 101 const startingBlockHeight = 101
ctx, cleanUp, err := createTestCtxFromFile(startingBlockHeight, ctx, cleanUp, err := createTestCtxFromFile(
basicGraphFilePath) startingBlockHeight, basicGraphFilePath,
)
if err != nil { if err != nil {
t.Fatalf("unable to create router: %v", err) t.Fatalf("unable to create router: %v", err)
} }
@ -1108,7 +1111,8 @@ func TestAddEdgeUnknownVertexes(t *testing.T) {
fundingTx, _, chanID, err := createChannelEdge(ctx, fundingTx, _, chanID, err := createChannelEdge(ctx,
bitcoinKey1.SerializeCompressed(), bitcoinKey1.SerializeCompressed(),
bitcoinKey2.SerializeCompressed(), bitcoinKey2.SerializeCompressed(),
10000, 500) 10000, 500,
)
if err != nil { if err != nil {
t.Fatalf("unable to create channel edge: %v", err) t.Fatalf("unable to create channel edge: %v", err)
} }
@ -1266,7 +1270,7 @@ func TestAddEdgeUnknownVertexes(t *testing.T) {
copy(targetPubKeyBytes[:], targetNode.SerializeCompressed()) copy(targetPubKeyBytes[:], targetNode.SerializeCompressed())
_, err = ctx.router.FindRoute( _, err = ctx.router.FindRoute(
ctx.router.selfNode.PubKeyBytes, ctx.router.selfNode.PubKeyBytes,
targetPubKeyBytes, paymentAmt, noRestrictions, targetPubKeyBytes, paymentAmt, noRestrictions, nil,
zpay32.DefaultFinalCLTVDelta, zpay32.DefaultFinalCLTVDelta,
) )
if err != nil { if err != nil {
@ -1309,7 +1313,7 @@ func TestAddEdgeUnknownVertexes(t *testing.T) {
// updated. // updated.
_, err = ctx.router.FindRoute( _, err = ctx.router.FindRoute(
ctx.router.selfNode.PubKeyBytes, ctx.router.selfNode.PubKeyBytes,
targetPubKeyBytes, paymentAmt, noRestrictions, targetPubKeyBytes, paymentAmt, noRestrictions, nil,
zpay32.DefaultFinalCLTVDelta, zpay32.DefaultFinalCLTVDelta,
) )
if err != nil { if err != nil {
@ -2632,12 +2636,14 @@ func TestRouterPaymentStateMachine(t *testing.T) {
hop2 := testGraph.aliasMap["c"] hop2 := testGraph.aliasMap["c"]
hops := []*route.Hop{ hops := []*route.Hop{
{ {
ChannelID: 1, ChannelID: 1,
PubKeyBytes: hop1, PubKeyBytes: hop1,
LegacyPayload: true,
}, },
{ {
ChannelID: 2, ChannelID: 2,
PubKeyBytes: hop2, PubKeyBytes: hop2,
LegacyPayload: true,
}, },
} }
@ -3270,14 +3276,16 @@ func TestSendToRouteStructuredError(t *testing.T) {
hop2 := ctx.aliases["c"] hop2 := ctx.aliases["c"]
hops := []*route.Hop{ hops := []*route.Hop{
{ {
ChannelID: 1, ChannelID: 1,
PubKeyBytes: hop1, PubKeyBytes: hop1,
AmtToForward: payAmt, AmtToForward: payAmt,
LegacyPayload: true,
}, },
{ {
ChannelID: 2, ChannelID: 2,
PubKeyBytes: hop2, PubKeyBytes: hop2,
AmtToForward: payAmt, AmtToForward: payAmt,
LegacyPayload: true,
}, },
} }