diff --git a/go.mod b/go.mod index 72d03d46f..6d41e2f5e 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/lightninglabs/pool v0.6.5-beta.0.20241015105339-044cb451b5df github.com/lightninglabs/pool/auctioneerrpc v1.1.2 github.com/lightninglabs/pool/poolrpc v1.0.0 - github.com/lightninglabs/taproot-assets v0.4.2-0.20241121145749-39d18891b674 + github.com/lightninglabs/taproot-assets v0.4.2-0.20241121022450-f12575cdccd6 github.com/lightningnetwork/lnd v0.18.4-beta.rc1 github.com/lightningnetwork/lnd/cert v1.2.2 github.com/lightningnetwork/lnd/fn v1.2.3 @@ -36,6 +36,7 @@ require ( github.com/urfave/cli v1.22.9 go.etcd.io/bbolt v1.3.11 golang.org/x/crypto v0.25.0 + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 golang.org/x/net v0.27.0 golang.org/x/sync v0.8.0 google.golang.org/grpc v1.65.0 @@ -201,7 +202,6 @@ require ( go.uber.org/mock v0.4.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.23.0 // indirect - golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/mod v0.17.0 // indirect golang.org/x/sys v0.22.0 // indirect golang.org/x/term v0.22.0 // indirect diff --git a/go.sum b/go.sum index 8143cf9c6..9f57f0447 100644 --- a/go.sum +++ b/go.sum @@ -1177,8 +1177,8 @@ github.com/lightninglabs/pool/poolrpc v1.0.0 h1:vvosrgNx9WXF4mcHGqLjZOW8wNM0q+BL github.com/lightninglabs/pool/poolrpc v1.0.0/go.mod h1:ZqpEpBFRMMBAerMmilEjh27tqauSXDwLaLR0O3jvmMA= github.com/lightninglabs/protobuf-go-hex-display v1.34.2-hex-display h1:w7FM5LH9Z6CpKxl13mS48idsu6F+cEZf0lkyiV+Dq9g= github.com/lightninglabs/protobuf-go-hex-display v1.34.2-hex-display/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= -github.com/lightninglabs/taproot-assets v0.4.2-0.20241121145749-39d18891b674 h1:lWSQJXBb2HbWXcbZn+4R4SKX9bnAJeHIeLf8VCEVw0I= -github.com/lightninglabs/taproot-assets v0.4.2-0.20241121145749-39d18891b674/go.mod h1:xWtQ/I7KAUIJlLU0fdiQgwRUXIKoXuNCjpqLcdge6eQ= +github.com/lightninglabs/taproot-assets v0.4.2-0.20241121022450-f12575cdccd6 h1:KkcQyRgMLOjLQxeSXdKdep5nsDgQvbMUn/IDmHdc1Qg= +github.com/lightninglabs/taproot-assets v0.4.2-0.20241121022450-f12575cdccd6/go.mod h1:xWtQ/I7KAUIJlLU0fdiQgwRUXIKoXuNCjpqLcdge6eQ= github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb h1:yfM05S8DXKhuCBp5qSMZdtSwvJ+GFzl94KbXMNB1JDY= github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb/go.mod h1:c0kvRShutpj3l6B9WtTsNTBUtjSmjZXbJd9ZBRQOSKI= github.com/lightningnetwork/lnd v0.18.4-beta.rc1 h1:z6hFKvtbfo8udPrIb81GbSoKlUWd06d4LRxTkD19IMQ= diff --git a/itest/assertions.go b/itest/assertions.go index 5b2d6f708..06560ba55 100644 --- a/itest/assertions.go +++ b/itest/assertions.go @@ -3,11 +3,13 @@ package itest import ( "context" "fmt" + "testing" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntest" "github.com/lightningnetwork/lnd/lntest/wait" "github.com/stretchr/testify/require" @@ -205,3 +207,27 @@ func assertChannelClosed(ctx context.Context, t *harnessTest, return closingTxid } + +func assertSweepExists(t *testing.T, node *HarnessNode, + witnessType walletrpc.WitnessType) { + + ctxb := context.Background() + err := wait.NoError(func() error { + pendingSweeps, err := node.WalletKitClient.PendingSweeps( + ctxb, &walletrpc.PendingSweepsRequest{}, + ) + if err != nil { + return err + } + + for _, sweep := range pendingSweeps.PendingSweeps { + if sweep.WitnessType == witnessType { + return nil + } + } + + return fmt.Errorf("failed to find second level sweep: %v", + toProtoJSON(t, pendingSweeps)) + }, defaultTimeout) + require.NoError(t, err) +} diff --git a/itest/assets_test.go b/itest/assets_test.go index df92870ba..97b399fe0 100644 --- a/itest/assets_test.go +++ b/itest/assets_test.go @@ -17,6 +17,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/davecgh/go-spew/spew" + tapfn "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/itest" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/rfq" @@ -42,6 +43,7 @@ import ( "github.com/lightningnetwork/lnd/macaroons" "github.com/lightningnetwork/lnd/record" "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/protobuf/proto" @@ -329,6 +331,11 @@ func locateAssetTransfers(t *testing.T, tapdClient *tapClient, transfer = forceCloseTransfer.Transfers[0] + if transfer.AnchorTxBlockHash == nil { + return fmt.Errorf("missing anchor block hash, " + + "transfer not confirmed") + } + return nil }, defaultTimeout) require.NoError(t, err) @@ -705,7 +712,7 @@ func sendAssetKeySendPayment(t *testing.T, src, dst *HarnessNode, amt uint64, }) require.NoError(t, err) - result, err := getAssetPaymentResult(stream) + result, err := getAssetPaymentResult(stream, false) require.NoError(t, err) require.Equal(t, expectedStatus, result.Status) @@ -784,7 +791,8 @@ func createAndPayNormalInvoice(t *testing.T, src, rfqPeer, dst *HarnessNode, require.NoError(t, err) numUnits, _ := payInvoiceWithAssets( - t, src, rfqPeer, invoiceResp, assetID, smallShards, + t, src, rfqPeer, invoiceResp.PaymentRequest, assetID, smallShards, + fn.None[lnrpc.Payment_PaymentStatus](), ) return numUnits @@ -849,8 +857,9 @@ func payInvoiceWithSatoshiLastHop(t *testing.T, payer *HarnessNode, } func payInvoiceWithAssets(t *testing.T, payer, rfqPeer *HarnessNode, - invoice *lnrpc.AddInvoiceResponse, assetID []byte, - smallShards bool) (uint64, rfqmath.BigIntFixedPoint) { + payReq string, assetID []byte, smallShards bool, + expectedPayStatus fn.Option[lnrpc.Payment_PaymentStatus]) (uint64, + rfqmath.BigIntFixedPoint) { ctxb := context.Background() ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) @@ -859,12 +868,12 @@ func payInvoiceWithAssets(t *testing.T, payer, rfqPeer *HarnessNode, payerTapd := newTapClient(t, payer) decodedInvoice, err := payer.DecodePayReq(ctxt, &lnrpc.PayReqString{ - PayReq: invoice.PaymentRequest, + PayReq: payReq, }) require.NoError(t, err) sendReq := &routerrpc.SendPaymentRequest{ - PaymentRequest: invoice.PaymentRequest, + PaymentRequest: payReq, TimeoutSeconds: int32(PaymentTimeout.Seconds()), FeeLimitMsat: 1_000_000, } @@ -904,9 +913,11 @@ func payInvoiceWithAssets(t *testing.T, payer, rfqPeer *HarnessNode, "with SCID %d", numUnits, msatPerUnit, peerPubKey, acceptedQuote.Scid) - result, err := getAssetPaymentResult(stream) + expectedStatus := expectedPayStatus.UnwrapOr(lnrpc.Payment_SUCCEEDED) + + result, err := getAssetPaymentResult(stream, expectedPayStatus.IsSome()) require.NoError(t, err) - require.Equal(t, lnrpc.Payment_SUCCEEDED, result.Status) + require.Equal(t, expectedStatus, result.Status) return numUnits, *rate } @@ -1057,6 +1068,74 @@ func assertPaymentHtlcAssets(t *testing.T, node *HarnessNode, payHash []byte, require.InDelta(t, assetAmount, totalAssetAmount, 1) } +type assetHodlInvoice struct { + preimage lntypes.Preimage + payReq string +} + +func createAssetHodlInvoice(t *testing.T, dstRfqPeer, dst *HarnessNode, + assetAmount uint64, assetID []byte) assetHodlInvoice { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + timeoutSeconds := int64(rfq.DefaultInvoiceExpiry.Seconds()) + + t.Logf("Asking peer %x for quote to buy assets to receive for "+ + "invoice over %d units; waiting up to %ds", + dstRfqPeer.PubKey[:], assetAmount, timeoutSeconds) + + dstTapd := newTapClient(t, dst) + + // As this is a hodl invoice, we'll also need to create a preimage + // external to lnd. + var preimage lntypes.Preimage + _, err := rand.Read(preimage[:]) + require.NoError(t, err) + + payHash := preimage.Hash() + + resp, err := dstTapd.AddInvoice(ctxt, &tchrpc.AddInvoiceRequest{ + AssetId: assetID, + AssetAmount: assetAmount, + PeerPubkey: dstRfqPeer.PubKey[:], + InvoiceRequest: &lnrpc.Invoice{ + Memo: fmt.Sprintf("this is an asset invoice over "+ + "%d units", assetAmount), + Expiry: timeoutSeconds, + }, + HodlInvoice: &tchrpc.HodlInvoice{ + PaymentHash: payHash[:], + }, + }) + require.NoError(t, err) + + decodedInvoice, err := dst.DecodePayReq(ctxt, &lnrpc.PayReqString{ + PayReq: resp.InvoiceResult.PaymentRequest, + }) + require.NoError(t, err) + + rpcRate := resp.AcceptedBuyQuote.AskAssetRate + rate, err := rfqrpc.UnmarshalFixedPoint(rpcRate) + require.NoError(t, err) + + assetUnits := rfqmath.NewBigIntFixedPoint(assetAmount, 0) + numMSats := rfqmath.UnitsToMilliSatoshi(assetUnits, *rate) + mSatPerUnit := float64(decodedInvoice.NumMsat) / float64(assetAmount) + + require.EqualValues(t, uint64(numMSats), uint64(decodedInvoice.NumMsat)) + + t.Logf("Got quote for %d sats at %v msat/unit from peer %x with SCID "+ + "%d", decodedInvoice.NumMsat, mSatPerUnit, dstRfqPeer.PubKey[:], + resp.AcceptedBuyQuote.Scid) + + return assetHodlInvoice{ + preimage: preimage, + payReq: resp.InvoiceResult.PaymentRequest, + } +} + func waitForSendEvent(t *testing.T, sendEvents taprpc.TaprootAssets_SubscribeSendEventsClient, expectedState tapfreighter.SendState) { @@ -1576,6 +1655,75 @@ func assertAssetBalance(t *testing.T, client *tapClient, assetID []byte, t.Logf("Failed to assert expected balance of %d, current "+ "assets: %v", expectedBalance, toProtoJSON(t, r)) + + utxos, err3 := client.ListUtxos( + ctxb, &taprpc.ListUtxosRequest{}, + ) + require.NoError(t, err3) + + t.Logf("Current UTXOs: %v", toProtoJSON(t, utxos)) + + t.Fatalf("Failed to assert balance: %v", err) + } +} + +// assertSpendableBalance differs from assertAssetBalance in that it asserts +// that the entire balance is spendable. We consider something spendable if we +// have a local script key for it. +func assertSpendableBalance(t *testing.T, client *tapClient, assetID []byte, + expectedBalance uint64) { + + t.Helper() + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, shortTimeout) + defer cancel() + + err := wait.NoError(func() error { + utxos, err := client.ListUtxos(ctxt, &taprpc.ListUtxosRequest{}) + if err != nil { + return err + } + + assets := tapfn.FlatMap( + maps.Values(utxos.ManagedUtxos), + func(utxo *taprpc.ManagedUtxo) []*taprpc.Asset { + return utxo.Assets + }, + ) + + relevantAssets := fn.Filter(func(utxo *taprpc.Asset) bool { + return bytes.Equal(utxo.AssetGenesis.AssetId, assetID) + }, assets) + + var assetSum uint64 + for _, asset := range relevantAssets { + if asset.ScriptKeyIsLocal { + assetSum += asset.Amount + } + } + + if assetSum != expectedBalance { + return fmt.Errorf("expected balance %d, got %d", + expectedBalance, assetSum) + } + + return nil + }, shortTimeout) + if err != nil { + r, err2 := client.ListAssets(ctxb, &taprpc.ListAssetRequest{}) + require.NoError(t, err2) + + t.Logf("Failed to assert expected balance of %d, current "+ + "assets: %v", expectedBalance, toProtoJSON(t, r)) + + utxos, err3 := client.ListUtxos( + ctxb, &taprpc.ListUtxosRequest{}, + ) + require.NoError(t, err3) + + t.Logf("Current UTXOs: %v", toProtoJSON(t, utxos)) + t.Fatalf("Failed to assert balance: %v", err) } } @@ -1729,3 +1877,93 @@ func toProtoJSON(t *testing.T, resp proto.Message) string { return string(jsonBytes) } + +func assertNumHtlcs(t *testing.T, node *HarnessNode, expected int) { + t.Helper() + + ctxb := context.Background() + + err := wait.NoError(func() error { + listChansRequest := &lnrpc.ListChannelsRequest{} + listChansResp, err := node.ListChannels(ctxb, listChansRequest) + if err != nil { + return err + } + + var numHtlcs int + for _, channel := range listChansResp.Channels { + numHtlcs += len(channel.PendingHtlcs) + } + + if numHtlcs != expected { + return fmt.Errorf("expected %v HTLCs, got %v, %v", + expected, numHtlcs, + spew.Sdump(toProtoJSON(t, listChansResp))) + } + + return nil + }, defaultTimeout) + require.NoError(t, err) +} + +type forceCloseExpiryInfo struct { + currentHeight uint32 + csvDelay uint32 + + cltvDelays map[lntypes.Hash]uint32 + + localAssetBalance uint64 + remoteAssetBalance uint64 + + t *testing.T + + node *HarnessNode +} + +func (f *forceCloseExpiryInfo) blockTillExpiry(hash lntypes.Hash) uint32 { + ctxb := context.Background() + nodeInfo, err := f.node.GetInfo(ctxb, &lnrpc.GetInfoRequest{}) + require.NoError(f.t, err) + + cltv, ok := f.cltvDelays[hash] + require.True(f.t, ok) + + f.t.Logf("current_height=%v, expiry=%v, mining %v blocks", + nodeInfo.BlockHeight, cltv, cltv-nodeInfo.BlockHeight) + + return cltv - nodeInfo.BlockHeight +} + +func newCloseExpiryInfo(t *testing.T, node *HarnessNode) forceCloseExpiryInfo { + ctxb := context.Background() + + listChansRequest := &lnrpc.ListChannelsRequest{} + listChansResp, err := node.ListChannels(ctxb, listChansRequest) + require.NoError(t, err) + + mainChan := listChansResp.Channels[0] + + nodeInfo, err := node.GetInfo(ctxb, &lnrpc.GetInfoRequest{}) + require.NoError(t, err) + + cltvs := make(map[lntypes.Hash]uint32) + for _, htlc := range mainChan.PendingHtlcs { + var payHash lntypes.Hash + copy(payHash[:], htlc.HashLock) + cltvs[payHash] = htlc.ExpirationHeight + } + + var assetData rfqmsg.JsonAssetChannel + err = json.Unmarshal(mainChan.CustomChannelData, &assetData) + require.NoError(t, err) + + return forceCloseExpiryInfo{ + csvDelay: mainChan.CsvDelay, + currentHeight: nodeInfo.BlockHeight, + cltvDelays: cltvs, + localAssetBalance: assetData.Assets[0].LocalBalance, + remoteAssetBalance: assetData.Assets[0].RemoteBalance, + t: t, + node: node, + } +} diff --git a/itest/litd_accounts_test.go b/itest/litd_accounts_test.go index 781f6a9d0..bc51a9b70 100644 --- a/itest/litd_accounts_test.go +++ b/itest/litd_accounts_test.go @@ -436,8 +436,8 @@ func getPaymentResult(stream routerrpc.Router_SendPaymentV2Client) ( } func getAssetPaymentResult( - s tapchannelrpc.TaprootAssetChannels_SendPaymentClient) (*lnrpc.Payment, - error) { + s tapchannelrpc.TaprootAssetChannels_SendPaymentClient, + isHodl bool) (*lnrpc.Payment, error) { // No idea why it makes a difference whether we wait before calling // s.Recv() or not, but it does. Without the sleep, the test will fail @@ -456,7 +456,14 @@ func getAssetPaymentResult( return nil, fmt.Errorf("unexpected message: %v", msg) } - if payment.Status != lnrpc.Payment_IN_FLIGHT { + // If this is a hodl payment, then we'll return the first expected + // response. Otherwise, we'll wait until the in flight clears to we can + // observe the other payment states. + switch { + case isHodl: + return payment, nil + + case payment.Status != lnrpc.Payment_IN_FLIGHT: return payment, nil } } diff --git a/itest/litd_custom_channels_test.go b/itest/litd_custom_channels_test.go index b936d9ab0..6a2d5dd53 100644 --- a/itest/litd_custom_channels_test.go +++ b/itest/litd_custom_channels_test.go @@ -24,6 +24,7 @@ import ( "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntest" "github.com/lightningnetwork/lnd/lntest/port" "github.com/lightningnetwork/lnd/lntest/wait" @@ -45,6 +46,8 @@ var ( } shortTimeout = time.Second * 5 + + defaultPaymentStatus = fn.None[lnrpc.Payment_PaymentStatus]() ) var ( @@ -222,7 +225,10 @@ func testCustomChannelsLarge(_ context.Context, net *NetworkHarness, invoiceResp := createAssetInvoice( t.t, erin, fabia, fabiaInvoiceAssetAmount, assetID, ) - payInvoiceWithAssets(t.t, charlie, dave, invoiceResp, assetID, false) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, false, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after invoice") invoiceResp2 := createAssetInvoice( @@ -234,7 +240,10 @@ func testCustomChannelsLarge(_ context.Context, net *NetworkHarness, // amount of zero. time.Sleep(time.Second * 1) - payInvoiceWithAssets(t.t, fabia, erin, invoiceResp2, assetID, false) + payInvoiceWithAssets( + t.t, fabia, erin, invoiceResp2.PaymentRequest, assetID, false, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after invoice 2") // Now we send a large invoice from Charlie to Dave. @@ -242,7 +251,10 @@ func testCustomChannelsLarge(_ context.Context, net *NetworkHarness, invoiceResp3 := createAssetInvoice( t.t, charlie, dave, largeInvoiceAmount, assetID, ) - payInvoiceWithAssets(t.t, charlie, dave, invoiceResp3, assetID, false) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp3.PaymentRequest, assetID, false, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after invoice 3") // Make sure the invoice on the receiver side and the payment on the @@ -431,7 +443,10 @@ func testCustomChannels(_ context.Context, net *NetworkHarness, invoiceResp := createAssetInvoice( t.t, dave, charlie, charlieInvoiceAmount, assetID, ) - payInvoiceWithAssets(t.t, dave, charlie, invoiceResp, assetID, true) + payInvoiceWithAssets( + t.t, dave, charlie, invoiceResp.PaymentRequest, assetID, true, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after invoice back") // Make sure the invoice on the receiver side and the payment on the @@ -493,7 +508,10 @@ func testCustomChannels(_ context.Context, net *NetworkHarness, invoiceResp = createAssetInvoice( t.t, charlie, dave, daveInvoiceAssetAmount, assetID, ) - payInvoiceWithAssets(t.t, charlie, dave, invoiceResp, assetID, true) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after invoice") charlieAssetBalance -= daveInvoiceAssetAmount @@ -535,7 +553,10 @@ func testCustomChannels(_ context.Context, net *NetworkHarness, invoiceResp = createAssetInvoice( t.t, erin, fabia, fabiaInvoiceAssetAmount1, assetID, ) - payInvoiceWithAssets(t.t, charlie, dave, invoiceResp, assetID, true) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after invoice") charlieAssetBalance -= fabiaInvoiceAssetAmount1 @@ -569,7 +590,10 @@ func testCustomChannels(_ context.Context, net *NetworkHarness, invoiceResp = createAssetInvoice( t.t, erin, fabia, fabiaInvoiceAssetAmount3, assetID, ) - payInvoiceWithAssets(t.t, charlie, dave, invoiceResp, assetID, true) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after invoice") charlieAssetBalance -= fabiaInvoiceAssetAmount3 @@ -587,7 +611,10 @@ func testCustomChannels(_ context.Context, net *NetworkHarness, invoiceResp = createAssetInvoice( t.t, dave, yara, yaraInvoiceAssetAmount1, assetID, ) - payInvoiceWithAssets(t.t, charlie, dave, invoiceResp, assetID, true) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after asset-to-asset") charlieAssetBalance -= yaraInvoiceAssetAmount1 @@ -920,7 +947,10 @@ func testCustomChannelsGroupedAsset(_ context.Context, net *NetworkHarness, invoiceResp := createAssetInvoice( t.t, charlie, dave, daveInvoiceAssetAmount, assetID, ) - payInvoiceWithAssets(t.t, charlie, dave, invoiceResp, assetID, true) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after invoice") // Make sure the invoice on the receiver side and the payment on the @@ -955,7 +985,10 @@ func testCustomChannelsGroupedAsset(_ context.Context, net *NetworkHarness, invoiceResp = createAssetInvoice( t.t, erin, fabia, fabiaInvoiceAssetAmount1, assetID, ) - payInvoiceWithAssets(t.t, charlie, dave, invoiceResp, assetID, true) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after invoice") charlieAssetBalance -= fabiaInvoiceAssetAmount1 @@ -989,7 +1022,10 @@ func testCustomChannelsGroupedAsset(_ context.Context, net *NetworkHarness, invoiceResp = createAssetInvoice( t.t, erin, fabia, fabiaInvoiceAssetAmount3, assetID, ) - payInvoiceWithAssets(t.t, charlie, dave, invoiceResp, assetID, true) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after invoice") charlieAssetBalance -= fabiaInvoiceAssetAmount3 @@ -1007,7 +1043,10 @@ func testCustomChannelsGroupedAsset(_ context.Context, net *NetworkHarness, invoiceResp = createAssetInvoice( t.t, dave, yara, yaraInvoiceAssetAmount1, assetID, ) - payInvoiceWithAssets(t.t, charlie, dave, invoiceResp, assetID, true) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after asset-to-asset") charlieAssetBalance -= yaraInvoiceAssetAmount1 @@ -1916,7 +1955,11 @@ func testCustomChannelsLiquidityEdgeCases(_ context.Context, invoiceResp := createAssetInvoice( t.t, charlie, dave, bigAssetAmount, assetID, ) - payInvoiceWithAssets(t.t, charlie, dave, invoiceResp, assetID, false) + + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, false, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after big asset payment (asset "+ "invoice, direct)") @@ -1959,7 +2002,10 @@ func testCustomChannelsLiquidityEdgeCases(_ context.Context, t.t, dave, charlie, bigAssetAmount, assetID, ) - payInvoiceWithAssets(t.t, yara, dave, invoiceResp, assetID, false) + payInvoiceWithAssets( + t.t, yara, dave, invoiceResp.PaymentRequest, assetID, false, + defaultPaymentStatus, + ) logBalance(t.t, nodes, assetID, "after big asset payment (asset "+ "invoice, multi-hop)") @@ -2516,7 +2562,8 @@ func testCustomChannelsOraclePricing(_ context.Context, require.EqualValues(t.t, 153_333_242, decodedInvoice.NumMsat) numUnits, rate := payInvoiceWithAssets( - t.t, charlie, dave, invoiceResp, assetID, false, + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, false, + defaultPaymentStatus, ) logBalance(t.t, nodes, assetID, "after invoice") @@ -2722,3 +2769,447 @@ func testCustomChannelsFee(_ context.Context, "min_relay_fee: ", tooLowFeeRateAmount.FeePerKWeight()) require.ErrorContains(t.t, err, errFeeRateTooLow) } + +// testCustomChannelsHtlcForceClose tests that we can force close a channel +// with HTLCs in both directions and that the HTLC outputs are correctly +// swept. +func testCustomChannelsHtlcForceClose(_ context.Context, net *NetworkHarness, + t *harnessTest) { + + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplate) + + // Zane will serve as our designated Universe node. + zane, err := net.NewNode( + t.t, "Zane", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + litdArgs = append(litdArgs, fmt.Sprintf( + "--taproot-assets.proofcourieraddr=%s://%s", + proof.UniverseRpcCourierType, zane.Cfg.LitAddr(), + )) + + // Next, we'll make Alice and Bob, who will be the main nodes under + // test. + alice, err := net.NewNode( + t.t, "Alice", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + bob, err := net.NewNode( + t.t, "Bob", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + // Now we'll connect all nodes, and also fund them with some coins. + nodes := []*HarnessNode{alice, bob} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + aliceTap := newTapClient(t.t, alice) + bobTap := newTapClient(t.t, bob) + + // Next, we'll mint an asset for Alice, who will be the node that opens + // the channel outbound. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, aliceTap, + []*mintrpc.MintAssetRequest{ + { + Asset: itestAsset, + }, + }, + ) + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + + t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) + syncUniverses(t.t, aliceTap, bob) + t.Logf("Universes synced between all nodes, distributing assets...") + + // With the assets created, and synced -- we'll now open the channel + // between Alice and Bob. + t.Logf("Opening asset channels...") + ctxb := context.Background() + assetFundResp, err := aliceTap.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: fundingAmount, + AssetId: assetID, + PeerPubkey: bob.PubKey[:], + FeeRateSatPerVbyte: 5, + }, + ) + require.NoError(t.t, err) + t.Logf("Funded channel between Alice and Bob: %v", assetFundResp) + + // With the channel open, mine a block to confirm it. + mineBlocks(t, net, 6, 1) + + // Before we start sending out payments, let's make sure each node can + // see the other one in the graph and has all required features. + require.NoError(t.t, t.lndHarness.AssertNodeKnown(alice, bob)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(bob, alice)) + + // First, we'll send over some funds from Alice to Bob, as we want Bob + // to be able to extend HTLCs in the other direction. + const ( + numPayments = 10 + keySendAmount = 1_000 + ) + for i := 0; i < numPayments; i++ { + sendAssetKeySendPayment( + t.t, alice, bob, keySendAmount, assetID, + fn.None[int64](), lnrpc.Payment_SUCCEEDED, + fn.None[lnrpc.PaymentFailureReason](), + ) + } + + // Now that both parties have some funds, we'll move onto the main test. + // + // We'll make 2 hodl invoice for each peer, so 4 total. From Alice's + // PoV, she'll have two outgoing HTLCs, and two incoming HTLCs. + var ( + bobHodlInvoices []assetHodlInvoice + aliceHodlInvoices []assetHodlInvoice + assetInvoiceAmt = 100 + ) + for i := 0; i < 2; i++ { + bobHodlInvoices = append( + bobHodlInvoices, createAssetHodlInvoice( + t.t, alice, bob, uint64(assetInvoiceAmt), + assetID, + ), + ) + aliceHodlInvoices = append( + aliceHodlInvoices, createAssetHodlInvoice( + t.t, bob, alice, uint64(assetInvoiceAmt), + assetID, + ), + ) + } + + // Now we'll have both Bob and Alice pay each other's invoices. We only + // care that they're in flight at this point, as they won't be settled + // yet. + for _, aliceInvoice := range aliceHodlInvoices { + payInvoiceWithAssets( + t.t, bob, alice, aliceInvoice.payReq, assetID, false, + fn.Some(lnrpc.Payment_IN_FLIGHT), + ) + } + for _, bobInvoice := range bobHodlInvoices { + payInvoiceWithAssets( + t.t, alice, bob, bobInvoice.payReq, assetID, false, + fn.Some(lnrpc.Payment_IN_FLIGHT), + ) + } + + // At this point, both sides should have 4 HTLCs active. + assertNumHtlcs(t.t, alice, 4) + assertNumHtlcs(t.t, bob, 4) + + // Before we force close, we'll grab the current height, the CSV delay + // needed, and also the absolute timeout of the set of active HTLCs. + closeExpiryInfo := newCloseExpiryInfo(t.t, alice) + + // With all of the HTLCs established, we'll now force close the channel + // with Alice. + t.Logf("Force close by Alice w/ HTLCs...") + aliceChanPoint := &lnrpc.ChannelPoint{ + OutputIndex: uint32(assetFundResp.OutputIndex), + FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: assetFundResp.Txid, + }, + } + _, closeTxid, err := net.CloseChannel(alice, aliceChanPoint, true) + require.NoError(t.t, err) + + t.Logf("Channel closed! Mining blocks, close_txid=%v", closeTxid) + + // Next, we'll mine a block which should start the clock ticking on the + // relative timeout for the Alice, and Bob. + // + // After this next block, both of them can start to sweep. + // + // For Alice, she'll go to the second level, revealing her preimage in + // the process. She'll then need to wait for the relative timeout to + // expire before she can sweep her output. + // + // For Bob, since the remote party (Alice) closed, he can try to sweep + // right away after initial confirmation. + mineBlocks(t, net, 1, 1) + + // After force closing, Bob should now have a transfer that tracks the + // force closed commitment transaction. + locateAssetTransfers(t.t, bobTap, *closeTxid) + + t.Logf("Settling Bob's hodl invoice") + + // At this point, the commitment transaction has been mined, and we have + // 4 total HTLCs on Alice's commitment transaction: + // + // * 2x outgoing HTLCs to Alice to Bob + // * 2x incoming HTLCs from Bob to Alice + // + // We'll leave half the HTLCs timeout, while pulling the other half. + // To start, we'll signal Bob to settle one of his incoming HTLCs on + // Alice's commitment transaction. For him, this is a remote success + // spend, so there's no CSV delay other than the 1 CSV (carve out), and + // he can spend directly from the commitment transaction. + _, err = bob.InvoicesClient.SettleInvoice( + ctxb, &invoicesrpc.SettleInvoiceMsg{ + Preimage: bobHodlInvoices[0].preimage[:], + }, + ) + require.NoError(t.t, err) + + // We'll pause here for Bob to extend the sweep request to the sweeper. + assertSweepExists( + t.t, bob, + walletrpc.WitnessType_TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS, + ) + + // We'll mine an empty block to get the sweeper to tick. + mineBlocks(t, net, 1, 0) + + sweepTx1, err := waitForNTxsInMempool( + net.Miner.Client, 1, shortTimeout, + ) + require.NoError(t.t, err) + + // Next, we'll mine an additional block, this should allow Bob to sweep + // both his commitment output, and the incoming HTLC that we just + // settled above. + mineBlocks(t, net, 1, 1) + + // At this point, we should have the next sweep transaction in the + // mempool: Bob's incoming HTLC sweep directly off the commitment + // transaction. + sweepTx2, err := waitForNTxsInMempool(net.Miner.Client, 1, shortTimeout) + require.NoError(t.t, err) + + // We'll now mine the next block, which should confirm Bob's HTLC sweep + // transaction. + mineBlocks(t, net, 1, 1) + + bobSweepTransfer1 := locateAssetTransfers(t.t, bobTap, *sweepTx1[0]) + bobSweepTransfer2 := locateAssetTransfers(t.t, bobTap, *sweepTx2[0]) + t.Logf("Bob's sweep transfer 1: %v", + toProtoJSON(t.t, bobSweepTransfer1)) + t.Logf("Bob's sweep transfer 2: %v", + toProtoJSON(t.t, bobSweepTransfer2)) + + t.Logf("Confirming Bob's remote HTLC success sweep") + + // Bob's balance should now reflect that he's gained the value of the + // HTLC, in addition to his settled balance. We need to subtract 1 from + // the final balance due to the rounding down of the asset amount during + // RFQ conversion. + bobExpectedBalance := closeExpiryInfo.remoteAssetBalance + + uint64(assetInvoiceAmt-1) + assertSpendableBalance( + t.t, bobTap, assetID, bobExpectedBalance, + ) + + // With Bob's HTLC settled, we'll now have Alice do the same. For her, + // it'll be a 2nd level sweep, which requires an extra transaction. + // + // Before, we do that though, enough blocks have passed so Alice can now + // sweep her to-local output. So we'll mine an extra block, then assert + // that she's swept everything properly. With the way the sweeper works, + // we need to mine one extra block before the sweeper picks things up. + mineBlocks(t, net, 1, 0) + time.Sleep(time.Second * 1) + mineBlocks(t, net, 1, 1) + + t.Logf("Confirming Alice's to-local sweep") + + // With this extra block mined, Alice's settled balance should be the + // starting balance, minus the 2 HTLCs, plus her settled balance. + aliceExpectedBalance := itestAsset.Amount - fundingAmount + aliceExpectedBalance += uint64(closeExpiryInfo.localAssetBalance) + assertSpendableBalance( + t.t, aliceTap, assetID, aliceExpectedBalance, + ) + + t.Logf("Settling Alice's hodl invoice") + + // With her commitment output swept above, we'll now settle one of + // Alice's incoming HTLCs. + _, err = alice.InvoicesClient.SettleInvoice( + ctxb, &invoicesrpc.SettleInvoiceMsg{ + Preimage: aliceHodlInvoices[0].preimage[:], + }, + ) + require.NoError(t.t, err) + + // We'll pause here for Alice to extend the sweep request to the + // sweeper. + assertSweepExists( + t.t, alice, + walletrpc.WitnessType_TAPROOT_HTLC_ACCEPTED_LOCAL_SUCCESS, + ) + + // We'll now mine a block, which should trigger Alice's broadcast of the + // second level sweep transaction. + sweepBlocks := mineBlocks(t, net, 1, 0) + + // If the block mined above didn't also mine our sweep, then we'll mine + // one final block which will confirm Alice's sweep transaction. + if len(sweepBlocks[0].Transactions) == 1 { + // With the sweep transaction in the mempool, we'll mine a block + // to confirm the sweep. + mineBlocks(t, net, 1, 1) + } + + t.Logf("Confirming Alice's second level remote HTLC success sweep") + + // Next, we'll mine enough blocks to trigger the CSV expiry so Alice can + // sweep the HTLC into her wallet. + mineBlocks(t, net, closeExpiryInfo.csvDelay, 0) + + // We'll pause here and wait until the sweeper recognizes that we've + // offered the second level sweep transaction. + assertSweepExists( + t.t, alice, + walletrpc.WitnessType_TAPROOT_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL, + ) + + t.Logf("Confirming Alice's local HTLC success sweep") + + // Now that we know the sweep was offered, we'll mine an extra block to + // actually trigger a sweeper broadcast. Due to an internal block race + // condition, the sweep transaction may have already been + // published+mined. If so, we don't need to mine the extra block. + sweepBlocks = mineBlocks(t, net, 1, 0) + + // If the block mined above didn't also mine our sweep, then we'll mine + // one final block which will confirm Alice's sweep transaction. + if len(sweepBlocks[0].Transactions) == 1 { + mineBlocks(t, net, 1, 1) + } + + // With the sweep transaction confirmed, Alice's balance should have + // incremented by the amt of the HTLC. + aliceExpectedBalance += uint64(assetInvoiceAmt - 1) + assertSpendableBalance( + t.t, aliceTap, assetID, aliceExpectedBalance, + ) + + t.Logf("Mining enough blocks to time out the remaining HTLCs") + + // At this point, we've swept two HTLCs: one from the remote commit, and + // one via the second layer. We'll now mine the remaining amount of + // blocks to time out the HTLCs. + blockToMine := closeExpiryInfo.blockTillExpiry( + aliceHodlInvoices[1].preimage.Hash(), + ) + mineBlocks(t, net, blockToMine, 0) + + // We'll wait for both Alice and Bob to present their respective sweeps + // to the sweeper. + assertSweepExists( + t.t, alice, + walletrpc.WitnessType_TAPROOT_HTLC_LOCAL_OFFERED_TIMEOUT, + ) + assertSweepExists( + t.t, bob, + walletrpc.WitnessType_TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT, + ) + + // We'll mine an extra block to trigger the sweeper. + mineBlocks(t, net, 1, 0) + + t.Logf("Confirming initial HTLC timeout txns") + + // Finally, we'll mine a single block to confirm them. + mineBlocks(t, net, 1, 2) + + // At this point, Bob's balance should be incremented by an additional + // HTLC value. + bobExpectedBalance += uint64(assetInvoiceAmt - 1) + assertSpendableBalance( + t.t, bobTap, assetID, bobExpectedBalance, + ) + + t.Logf("Mining extra blocks for Alice's CSV to expire on 2nd level txn") + + // Next, we'll mine 4 additional blocks to Alice's CSV delay expires for + // the second level timeout output. + mineBlocks(t, net, closeExpiryInfo.csvDelay, 0) + + // Wait for Alice to extend the second level output to the sweeper + // before we mine the next block to the sweeper. + assertSweepExists( + t.t, alice, + walletrpc.WitnessType_TAPROOT_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL, + ) + + t.Logf("Confirming Alice's final timeout sweep") + + // With the way the sweeper works, we'll now need to mine an extra block + // to trigger the sweep. + sweepBlocks = mineBlocks(t, net, 1, 0) + + // If the block mined above didn't also mine our sweep, then we'll mine + // one final block which will confirm Alice's sweep transaction. + if len(sweepBlocks[0].Transactions) == 1 { + // We'll mine one final block which will confirm Alice's sweep + // transaction. + mineBlocks(t, net, 1, 1) + } + + // Finally, we'll assert that Alice's balance has been incremented by + // the timeout value. + aliceExpectedBalance += uint64(assetInvoiceAmt - 1) + assertSpendableBalance( + t.t, aliceTap, assetID, aliceExpectedBalance, + ) + + t.Logf("Sending all settled funds to Zane") + + // As a final sanity check, both Alice and Bob should be able to send + // their entire balances to Zane, our 3rd party. + // + // We'll make two addrs for Zane, one for Alice, and one for bob. + zaneTap := newTapClient(t.t, zane) + aliceAddr, err := zaneTap.NewAddr(ctxb, &taprpc.NewAddrRequest{ + Amt: aliceExpectedBalance, + AssetId: assetID, + ProofCourierAddr: fmt.Sprintf( + "%s://%s", proof.UniverseRpcCourierType, + zaneTap.node.Cfg.LitAddr(), + ), + }) + require.NoError(t.t, err) + bobAddr, err := zaneTap.NewAddr(ctxb, &taprpc.NewAddrRequest{ + Amt: bobExpectedBalance, + AssetId: assetID, + ProofCourierAddr: fmt.Sprintf( + "%s://%s", proof.UniverseRpcCourierType, + zaneTap.node.Cfg.LitAddr(), + ), + }) + require.NoError(t.t, err) + + _, err = aliceTap.SendAsset(ctxb, &taprpc.SendAssetRequest{ + TapAddrs: []string{aliceAddr.Encoded}, + }) + require.NoError(t.t, err) + mineBlocks(t, net, 1, 1) + + itest.AssertNonInteractiveRecvComplete(t.t, zaneTap, 1) + + _, err = bobTap.SendAsset(ctxb, &taprpc.SendAssetRequest{ + TapAddrs: []string{bobAddr.Encoded}, + }) + require.NoError(t.t, err) + mineBlocks(t, net, 1, 1) + + itest.AssertNonInteractiveRecvComplete(t.t, zaneTap, 2) + + // Zane's balance should now be the sum of Alice's and Bob's balances. + zaneExpectedBalance := aliceExpectedBalance + bobExpectedBalance + assertSpendableBalance( + t.t, zaneTap, assetID, zaneExpectedBalance, + ) +} diff --git a/itest/litd_node.go b/itest/litd_node.go index ce5c04d98..c9f4a5fa2 100644 --- a/itest/litd_node.go +++ b/itest/litd_node.go @@ -656,6 +656,8 @@ func (hn *HarnessNode) Start(litdBinary string, litdError chan<- error, return err } + fmt.Printf("Starting node=%v, pid=%v\n", hn.Cfg.Name, hn.cmd.Process.Pid) + // Launch a new goroutine which that bubbles up any potential fatal // process errors to the goroutine running the tests. hn.processExit = make(chan struct{}) diff --git a/itest/litd_test_list_on_test.go b/itest/litd_test_list_on_test.go index 6f01d736c..2bee9ca63 100644 --- a/itest/litd_test_list_on_test.go +++ b/itest/litd_test_list_on_test.go @@ -48,6 +48,10 @@ var allTestCases = []*testCase{ name: "test custom channels liquidity", test: testCustomChannelsLiquidityEdgeCases, }, + { + name: "test custom channels htlc force close", + test: testCustomChannelsHtlcForceClose, + }, { name: "test custom channels balance consistency", test: testCustomChannelsBalanceConsistency,