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

multi: add BuildOnion, SendOnion, and TrackOnion RPCs #10

Draft
wants to merge 12 commits into
base: master
Choose a base branch
from
4 changes: 4 additions & 0 deletions itest/list_on_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -626,4 +626,8 @@ var allTestCases = []*lntest.TestCase{
Name: "send onion",
TestFunc: testSendOnion,
},
{
Name: "track onion",
TestFunc: testTrackOnion,
},
}
186 changes: 186 additions & 0 deletions itest/lnd_sendonion_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package itest

import (
"context"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/htlcswitch"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/routerrpc"
"github.com/lightningnetwork/lnd/lntest"
Expand All @@ -10,6 +15,10 @@
"github.com/stretchr/testify/require"
)

// const (
// defaultTimeout = 30 * time.Second
// )

func testSendOnion(ht *lntest.HarnessTest) {
Copy link

@yyforyongyu yyforyongyu May 23, 2024

Choose a reason for hiding this comment

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

I'm still learning the goals here - so in this test setup, we always want to have Bob being the first hop, and the rest can be anyone?

If the goal is to ensure payment goes through Alice -> Bob, I think we can do something like this instead,

  1. Bob queries a route to Dave via QueryRoutes.
  2. Alice inserts a hop in the above route, and builds the full path via BuildRoute
  3. Alice sends the payment via SendToRouteV2.

Copy link
Owner Author

@calvinrzachman calvinrzachman May 23, 2024

Choose a reason for hiding this comment

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

I would say the goal is more to verify that Alice's node is able to correctly accept and initiate a payment using the SendOnion RPC. This is how I have conceptualized the differences between various payment sending RPCS:

  • SendPaymentV2:
    • Accepts either an invoice or a destination public key in the case of a key send.
    • The payment route is computed by the daemon.
    • Onion is computed by the daemon.
    • Caller does not have privacy from daemon operator.
  • SendToRouteV2:
    • Accepts a route - a list of hops in JSON - to use for the payment.
    • Payment route is computed by the caller (implies access to channel graph).
    • Onion is computed by the daemon.
    • Caller does not have privacy from daemon operator.
  • SendOnion:
    • Payment route is computed by the caller.
    • Onion is computed by the caller.
    • Caller does have privacy from daemon operator (?) The daemon just forwards the onion out the first hop!

There are a couple envisioned uses for a SendOnion style RPC that I know of:

  1. If we allow the ChannelRouter to run in a separate process from the main lnd binary, then path finding would be done by this external process (eg: lightning proxy) and then the onion delivered to lnd.
    • NOTE: This is the result of trying to re-use the ChannelRouter code with minimal changes. It is possible that there are other ways to do this, however they may require either heavier lift/change set to the ChannelRouter or that we avoid re-using the existing ChannelRouter code all together.
  2. A hosted node provider could run the node on behalf of its customers. These customers could perform path finding and onion construction on their own device and then submit the onions to be sent by their node running in the hosted infrastructure. I believe this is what Core Lightning calls "oblivious sends" and they market as a way to maintain privacy from the provider in something like Green Light.


// Create a four-node context consisting of Alice, Bob and two new
Expand Down Expand Up @@ -142,4 +151,181 @@

// The invoice should show as settled for Dave.
ht.AssertInvoiceSettled(dave, invoices[0].PaymentAddr)

// TODO(calvin): Other things to check:
// - Error conditions/handling (server handles with decryptor or caller
// handles encrypted error blobs from server)
// - That we successfully convert pubkey --> channel when there are
// multiple channels, some of which can carry the payment and other
// which cannot.
// - Send the same onion again. Send the same onion again but mark it
// with a different attempt ID.
//
// If we send again, our node does forward the onion but the first hop
// considers it a replayed onion.
// 2024-05-01 15:54:18.364 [ERR] HSWC: unable to process onion packet: sphinx packet replay attempted
// 2024-05-01 15:54:18.364 [ERR] HSWC: ChannelLink(a680b373941e2e056e7b98007cc8cee933331e28981474b34d4275bb94cd17fe:0): unable to decode onion hop iterator: InvalidOnionVersion
// 2024-05-01 15:54:18.364 [DBG] PEER: Peer(0352f454dd5e09cd3e979cbace6fc6727cfa9a1eaa878a452ce63b221f51771a74): Sending UpdateFailMalformedHTLC(chan_id=fe17cd94bb75424db3741498281e3333e9cec87c00987b6e052e1e9473b380a6, id=1, fail_code=InvalidOnionVersion) to 0352f454dd5e09cd3e979cbace6fc6727cfa9a1eaa878a452ce63b221f51771a74@127.0.0.1:63567
// If we randomize the payment hash, first hop says bad HMAC.
//
// - Send different onion but with same attempt ID.
}

func testTrackOnion(ht *lntest.HarnessTest) {

// Create a four-node context consisting of Alice, Bob and two new
// nodes: Carol and Dave. This will provide a 4 node, 3 channel topology.
// Alice will make a channel with Bob, and Bob with Carol, and Carol
// with Dave such that we arrive at the network topology:
// Alice -> Bob -> Carol -> Dave
alice, bob := ht.Alice, ht.Bob
carol := ht.NewNode("carol", nil)
dave := ht.NewNode("dave", nil)

// Connect nodes to ensure propagation of channels.
ht.EnsureConnected(alice, bob)
ht.EnsureConnected(bob, carol)
ht.EnsureConnected(carol, dave)

const chanAmt = btcutil.Amount(100000)

// Open a channel with 100k satoshis between Alice and Bob with Alice
// being the sole funder of the channel.
chanPointAlice := ht.OpenChannel(
alice, bob, lntest.OpenChannelParams{Amt: chanAmt},
)
defer ht.CloseChannel(alice, chanPointAlice)

// We'll create Dave and establish a channel to Alice. Dave will be
// running an older node that requires the legacy onion payload.
ht.FundCoins(btcutil.SatoshiPerBitcoin, dave)
chanPointBob := ht.OpenChannel(
bob, carol, lntest.OpenChannelParams{Amt: chanAmt},
)
defer ht.CloseChannel(bob, chanPointBob)

// Next, we'll create Carol and establish a channel to from her to Dave.
ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
chanPointCarol := ht.OpenChannel(
carol, dave, lntest.OpenChannelParams{Amt: chanAmt},
)
defer ht.CloseChannel(carol, chanPointCarol)

// Make sure Alice knows the channel between Bob and Carol.
ht.AssertTopologyChannelOpen(alice, chanPointBob)
ht.AssertTopologyChannelOpen(alice, chanPointCarol)

const paymentAmt = 10000

// Query for routes to pay from Alice to Dave.
routesReq := &lnrpc.QueryRoutesRequest{
PubKey: dave.PubKeyStr,
Amt: paymentAmt,
}
routes := alice.RPC.QueryRoutes(routesReq)
route := routes.Routes[0]

finalHop := route.Hops[len(route.Hops)-1]
finalHop.MppRecord = &lnrpc.MPPRecord{
PaymentAddr: ht.Random32Bytes(),
TotalAmtMsat: int64(lnwire.NewMSatFromSatoshis(paymentAmt)),
}

ht.Logf("Found route from Alice to Dave: %+v", route)

// Build the onion to use for our payment.
paymentHash := ht.Random32Bytes()
onionReq := &routerrpc.BuildOnionRequest{
Route: route,
PaymentHash: paymentHash,
}
onionResp := alice.RPC.BuildOnion(onionReq)
ht.Logf("Constructed onion: %+v w/ key: %x", onionResp.OnionBlob,
onionResp.SessionKey)

// Dispatch a payment via SendOnion.
firstHop := bob.PubKey
sendReq := &routerrpc.SendOnionRequest{
FirstHopPubkey: firstHop[:],
Amount: route.TotalAmtMsat,
Timelock: route.TotalTimeLock,
PaymentHash: paymentHash,
OnionBlob: onionResp.OnionBlob,
AttemptId: 1,
}
ht.Logf("Sending onion w/ amt=%d (msat) to %x",
sendReq.Amount, firstHop)

resp := alice.RPC.SendOnion(sendReq)
ht.Logf("SendOnion response: %+v", resp)

serverErrorStr := ""
clientErrorStr := ""

// Track the payment providing all necessary information to delegate
// error decryption to the server.
//
// NOTE(calvin): We expect this to fail as Dave is not expecting payment.
ctxt, _ := context.WithTimeout(context.Background(), defaultTimeout)

Check failure on line 269 in itest/lnd_sendonion_test.go

View workflow job for this annotation

GitHub Actions / lint code

lostcancel: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak (govet)
trackReq := &routerrpc.TrackOnionRequest{
AttemptId: 1,
PaymentHash: paymentHash,
SessionKey: onionResp.SessionKey,
HopPubkeys: onionResp.HopPubkeys,
}
trackResp, clearErr := alice.RPC.Router.TrackOnion(ctxt, trackReq)
if clearErr != nil {
ht.Logf("Encountered error while tracking onion: %v", clearErr)
}
ht.Logf("Tracked payment via onion: %+v", trackResp)
serverErrorStr = clearErr.Error()

// Now we'll track the same payment attempt, but we'll specify that
Copy link
Owner Author

Choose a reason for hiding this comment

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

Trying to figure out the error handling across RPC boundary between server and client. Seems we could either use the gRPC status package or the (Send/Track)OnionResponse proto message itself to transport "rich error detail" (code + message) between server and client.

Depending on the RPC client (eg: lightning proxy with central instantiation of ChannelRouter, self-sovereign client with hosted node operator service acting as oblivious sender, etc.), these errors would be handled differently.

// we want to handle the error decryption ourselves client side.
trackReq = &routerrpc.TrackOnionRequest{
AttemptId: 1,
PaymentHash: paymentHash,
}
trackResp, err := alice.RPC.Router.TrackOnion(ctxt, trackReq)
if err != nil {
ht.Logf("Encountered error while tracking onion: %v", err)
}
ht.Logf("Tracked payment via onion: %+v", trackResp)

// Decrypt and inspect the error from the TrackOnion RPC response.
sessionKey, _ := btcec.PrivKeyFromBytes(onionResp.SessionKey)
var pubKeys []*btcec.PublicKey
for _, keyBytes := range onionResp.HopPubkeys {
pubKey, err := btcec.ParsePubKey(keyBytes)
if err != nil {
ht.Fatalf("Failed to parse public key: %v", err)
}
pubKeys = append(pubKeys, pubKey)
}

// Construct the circuit to create the error decryptor
circuit := reconstructCircuit(sessionKey, pubKeys)
errorDecryptor := &htlcswitch.SphinxErrorDecrypter{
OnionErrorDecrypter: sphinx.NewOnionErrorDecrypter(circuit),
}

// Simulate an RPC client decrypting the onion error.
encryptedError := lnwire.OpaqueReason(trackResp.EncryptedError)
forwardingError, err := errorDecryptor.DecryptError(encryptedError)
require.Nil(ht, err, "unable to decrypt error")

ht.Logf("Decrypted error: %+v", forwardingError)
clientErrorStr = forwardingError.Error()

ht.Logf("Server-side decrypted error: %s", serverErrorStr)
ht.Logf("Client-side decrypted error: %s", clientErrorStr)
}

func reconstructCircuit(sessionKey *btcec.PrivateKey,
pubKeys []*btcec.PublicKey) *sphinx.Circuit {

return &sphinx.Circuit{
SessionKey: sessionKey,
PaymentPath: pubKeys,
}
}
Loading