From 23039a92b04a74ccd276a120ca63ce1f06159e38 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Thu, 23 May 2024 13:56:41 +0200 Subject: [PATCH] itest: add custom channel integration test Co-authored-by: Olaoluwa Osuntokun Co-authored-by: Gijs van Dam Co-authored-by: George Tsagkarelis --- go.mod | 4 +- itest/assertions.go | 46 +- itest/assets_test.go | 2120 ++++++++++++++++ itest/litd_accounts_test.go | 42 + itest/litd_custom_channels_test.go | 3807 ++++++++++++++++++++++++++++ itest/litd_node.go | 38 + itest/litd_test.go | 8 +- itest/litd_test_list_on_test.go | 52 + itest/log.go | 24 + itest/network_harness.go | 103 +- itest/oracle_test.go | 279 ++ 11 files changed, 6509 insertions(+), 14 deletions(-) create mode 100644 itest/assets_test.go create mode 100644 itest/litd_custom_channels_test.go create mode 100644 itest/log.go create mode 100644 itest/oracle_test.go diff --git a/go.mod b/go.mod index b3971892..8be9bf82 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c github.com/btcsuite/btcwallet/walletdb v1.4.4 + github.com/davecgh/go-spew v1.1.1 github.com/go-errors/errors v1.0.1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 github.com/improbable-eng/grpc-web v0.12.0 @@ -35,6 +36,7 @@ require ( github.com/urfave/cli v1.22.9 go.etcd.io/bbolt v1.3.11 golang.org/x/crypto v0.31.0 + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 golang.org/x/net v0.27.0 golang.org/x/sync v0.10.0 google.golang.org/grpc v1.65.0 @@ -73,7 +75,6 @@ require ( github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect github.com/coreos/go-systemd/v22 v22.3.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/decred/dcrd/lru v1.1.2 // indirect @@ -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.28.0 // indirect golang.org/x/term v0.27.0 // indirect diff --git a/itest/assertions.go b/itest/assertions.go index 51a6feaf..f6518fea 100644 --- a/itest/assertions.go +++ b/itest/assertions.go @@ -3,14 +3,18 @@ package itest import ( "context" "fmt" + "testing" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/taprpc" "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" + "google.golang.org/protobuf/proto" ) // shutdownAndAssert shuts down the given node and asserts that no errors @@ -172,24 +176,25 @@ func assertChannelClosed(ctx context.Context, t *harnessTest, // block. block := mineBlocks(t, net, 1, 1)[0] - closingTxid, err := net.WaitForChannelClose(closeUpdates) + closingUpdate, err := net.WaitForChannelClose(closeUpdates) require.NoError(t.t, err, "error while waiting for channel close") + closingTxid, err := chainhash.NewHash(closingUpdate.ClosingTxid) + require.NoError(t.t, err) assertTxInBlock(t, block, closingTxid) // Finally, the transaction should no longer be in the waiting close // state as we've just mined a block that should include the closing // transaction. err = wait.Predicate(func() bool { - pendingChansRequest := &lnrpc.PendingChannelsRequest{} - pendingChanResp, err := node.PendingChannels( - ctx, pendingChansRequest, + resp, err := node.PendingChannels( + ctx, &lnrpc.PendingChannelsRequest{}, ) if err != nil { return false } - for _, pendingClose := range pendingChanResp.WaitingCloseChannels { + for _, pendingClose := range resp.WaitingCloseChannels { if pendingClose.Channel.ChannelPoint == chanPointStr { return false } @@ -203,3 +208,34 @@ 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) +} + +func toProtoJSON(t *testing.T, resp proto.Message) string { + jsonBytes, err := taprpc.ProtoJSONMarshalOpts.Marshal(resp) + require.NoError(t, err) + + return string(jsonBytes) +} diff --git a/itest/assets_test.go b/itest/assets_test.go new file mode 100644 index 00000000..b46c5f36 --- /dev/null +++ b/itest/assets_test.go @@ -0,0 +1,2120 @@ +package itest + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "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" + "github.com/lightninglabs/taproot-assets/rfqmath" + "github.com/lightninglabs/taproot-assets/rfqmsg" + "github.com/lightninglabs/taproot-assets/tapfreighter" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightninglabs/taproot-assets/taprpc/assetwalletrpc" + "github.com/lightninglabs/taproot-assets/taprpc/mintrpc" + "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" + tchrpc "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc" + "github.com/lightninglabs/taproot-assets/taprpc/tapdevrpc" + "github.com/lightninglabs/taproot-assets/taprpc/universerpc" + "github.com/lightninglabs/taproot-assets/tapscript" + "github.com/lightningnetwork/lnd/fn" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntest/rpc" + "github.com/lightningnetwork/lnd/lntest/wait" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" + "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" + "gopkg.in/macaroon.v2" +) + +// PaymentTimeout is the default payment timeout we use in our tests. +const ( + PaymentTimeout = 12 * time.Second + DefaultPushSat int64 = 1062 +) + +// nolint: lll +var ( + failureNoBalance = lnrpc.PaymentFailureReason_FAILURE_REASON_INSUFFICIENT_BALANCE + failureNoRoute = lnrpc.PaymentFailureReason_FAILURE_REASON_NO_ROUTE + failureIncorrectDetails = lnrpc.PaymentFailureReason_FAILURE_REASON_INCORRECT_PAYMENT_DETAILS + failureTimeout = lnrpc.PaymentFailureReason_FAILURE_REASON_TIMEOUT + failureNone = lnrpc.PaymentFailureReason_FAILURE_REASON_NONE +) + +// createTestAssetNetwork sends asset funds from Charlie to Dave and Erin, so +// they can fund asset channels with Yara and Fabia, respectively. So the asset +// channels created are Charlie->Dave, Dave->Yara, Erin->Fabia. The channels +// are then confirmed and balances asserted. +func createTestAssetNetwork(t *harnessTest, net *NetworkHarness, charlieTap, + daveTap, erinTap, fabiaTap, yaraTap, universeTap *tapClient, + mintedAsset *taprpc.Asset, assetSendAmount, charlieFundingAmount, + daveFundingAmount, + erinFundingAmount uint64, pushSat int64) (*lnrpc.ChannelPoint, + *lnrpc.ChannelPoint, *lnrpc.ChannelPoint) { + + ctxb := context.Background() + assetID := mintedAsset.AssetGenesis.AssetId + var groupKey []byte + if mintedAsset.AssetGroup != nil { + groupKey = mintedAsset.AssetGroup.TweakedGroupKey + } + + fundingScriptTree := tapscript.NewChannelFundingScriptTree() + fundingScriptKey := fundingScriptTree.TaprootKey + fundingScriptTreeBytes := fundingScriptKey.SerializeCompressed() + + // We need to send some assets to Dave, so he can fund an asset channel + // with Yara. + daveAddr, err := daveTap.NewAddr(ctxb, &taprpc.NewAddrRequest{ + Amt: assetSendAmount, + AssetId: assetID, + ProofCourierAddr: fmt.Sprintf( + "%s://%s", proof.UniverseRpcCourierType, + charlieTap.node.Cfg.LitAddr(), + ), + }) + require.NoError(t.t, err) + + t.Logf("Sending %v asset units to Dave...", assetSendAmount) + + // Send the assets to Dave. + itest.AssertAddrCreated(t.t, daveTap, mintedAsset, daveAddr) + sendResp, err := charlieTap.SendAsset(ctxb, &taprpc.SendAssetRequest{ + TapAddrs: []string{daveAddr.Encoded}, + }) + require.NoError(t.t, err) + itest.ConfirmAndAssertOutboundTransfer( + t.t, t.lndHarness.Miner.Client, charlieTap, sendResp, assetID, + []uint64{mintedAsset.Amount - assetSendAmount, assetSendAmount}, + 0, 1, + ) + itest.AssertNonInteractiveRecvComplete(t.t, daveTap, 1) + + // We need to send some assets to Erin, so he can fund an asset channel + // with Fabia. + erinAddr, err := erinTap.NewAddr(ctxb, &taprpc.NewAddrRequest{ + Amt: assetSendAmount, + AssetId: assetID, + ProofCourierAddr: fmt.Sprintf( + "%s://%s", proof.UniverseRpcCourierType, + charlieTap.node.Cfg.LitAddr(), + ), + }) + require.NoError(t.t, err) + + t.Logf("Sending %v asset units to Erin...", assetSendAmount) + + // Send the assets to Erin. + itest.AssertAddrCreated(t.t, erinTap, mintedAsset, erinAddr) + sendResp, err = charlieTap.SendAsset(ctxb, &taprpc.SendAssetRequest{ + TapAddrs: []string{erinAddr.Encoded}, + }) + require.NoError(t.t, err) + itest.ConfirmAndAssertOutboundTransfer( + t.t, t.lndHarness.Miner.Client, charlieTap, sendResp, assetID, + []uint64{ + mintedAsset.Amount - 2*assetSendAmount, assetSendAmount, + }, 1, 2, + ) + itest.AssertNonInteractiveRecvComplete(t.t, erinTap, 1) + + t.Logf("Opening asset channels...") + + // The first channel we create has a push amount, so Charlie can receive + // payments immediately and not run into the channel reserve issue. + fundRespCD, err := charlieTap.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: charlieFundingAmount, + AssetId: assetID, + PeerPubkey: daveTap.node.PubKey[:], + FeeRateSatPerVbyte: 5, + PushSat: pushSat, + }, + ) + require.NoError(t.t, err) + t.Logf("Funded channel between Charlie and Dave: %v", fundRespCD) + + fundRespDY, err := daveTap.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: daveFundingAmount, + AssetId: assetID, + PeerPubkey: yaraTap.node.PubKey[:], + FeeRateSatPerVbyte: 5, + }, + ) + require.NoError(t.t, err) + t.Logf("Funded channel between Dave and Yara: %v", fundRespDY) + + fundRespEF, err := erinTap.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: erinFundingAmount, + AssetId: assetID, + PeerPubkey: fabiaTap.node.PubKey[:], + FeeRateSatPerVbyte: 5, + PushSat: pushSat, + }, + ) + require.NoError(t.t, err) + t.Logf("Funded channel between Erin and Fabia: %v", fundRespEF) + + // Make sure the pending channel shows up in the list and has the + // custom records set as JSON. + assertPendingChannels( + t.t, charlieTap.node, mintedAsset, 1, charlieFundingAmount, 0, + ) + assertPendingChannels( + t.t, daveTap.node, mintedAsset, 2, daveFundingAmount, + charlieFundingAmount, + ) + assertPendingChannels( + t.t, erinTap.node, mintedAsset, 1, erinFundingAmount, 0, + ) + + // Now that we've looked at the pending channels, let's actually confirm + // all three of them. + mineBlocks(t, net, 6, 3) + + // We'll be tracking the expected asset balances throughout the test, so + // we can assert it after each action. + charlieAssetBalance := mintedAsset.Amount - 2*assetSendAmount - + charlieFundingAmount + daveAssetBalance := assetSendAmount - daveFundingAmount + erinAssetBalance := assetSendAmount - erinFundingAmount + + // After opening the channels, the asset balance of the funding nodes + // should have been decreased with the funding amount. The asset with + // the funding output was imported into the asset DB but are kept out of + // the balance reporting by tapd. + assertAssetBalance(t.t, charlieTap, assetID, charlieAssetBalance) + assertAssetBalance(t.t, daveTap, assetID, daveAssetBalance) + assertAssetBalance(t.t, erinTap, assetID, erinAssetBalance) + + // There should only be a single asset piece for Charlie, the one in the + // channel. + assertNumAssetOutputs(t.t, charlieTap, assetID, 1) + assertAssetExists( + t.t, charlieTap, assetID, charlieFundingAmount, + fundingScriptKey, false, true, true, + ) + + // Dave should just have one asset piece, since we used the full amount + // for the channel opening. + assertNumAssetOutputs(t.t, daveTap, assetID, 1) + assertAssetExists( + t.t, daveTap, assetID, daveFundingAmount, fundingScriptKey, + false, true, true, + ) + + // Erin should just have two equally sized asset pieces, the change and + // the funding transaction. + assertNumAssetOutputs(t.t, erinTap, assetID, 2) + assertAssetExists( + t.t, erinTap, assetID, assetSendAmount-erinFundingAmount, nil, + true, false, false, + ) + assertAssetExists( + t.t, erinTap, assetID, erinFundingAmount, fundingScriptKey, + false, true, true, + ) + + // Assert that the proofs for both channels has been uploaded to the + // designated Universe server. + assertUniverseProofExists( + t.t, universeTap, assetID, groupKey, fundingScriptTreeBytes, + fmt.Sprintf("%v:%v", fundRespCD.Txid, fundRespCD.OutputIndex), + ) + assertUniverseProofExists( + t.t, universeTap, assetID, groupKey, fundingScriptTreeBytes, + fmt.Sprintf("%v:%v", fundRespDY.Txid, fundRespDY.OutputIndex), + ) + assertUniverseProofExists( + t.t, universeTap, assetID, groupKey, fundingScriptTreeBytes, + fmt.Sprintf("%v:%v", fundRespEF.Txid, fundRespEF.OutputIndex), + ) + + // Make sure the channel shows the correct asset information. + assertAssetChan( + t.t, charlieTap.node, daveTap.node, charlieFundingAmount, + mintedAsset, + ) + assertAssetChan( + t.t, daveTap.node, yaraTap.node, daveFundingAmount, mintedAsset, + ) + assertAssetChan( + t.t, erinTap.node, fabiaTap.node, erinFundingAmount, + mintedAsset, + ) + + chanPointCD := &lnrpc.ChannelPoint{ + OutputIndex: uint32(fundRespCD.OutputIndex), + FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: fundRespCD.Txid, + }, + } + chanPointDY := &lnrpc.ChannelPoint{ + OutputIndex: uint32(fundRespDY.OutputIndex), + FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: fundRespDY.Txid, + }, + } + chanPointEF := &lnrpc.ChannelPoint{ + OutputIndex: uint32(fundRespEF.OutputIndex), + FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: fundRespEF.Txid, + }, + } + + return chanPointCD, chanPointDY, chanPointEF +} + +func assertNumAssetUTXOs(t *testing.T, tapdClient *tapClient, + numUTXOs int) *taprpc.ListUtxosResponse { + + ctxb := context.Background() + + var clientUTXOs *taprpc.ListUtxosResponse + err := wait.NoError(func() error { + var err error + clientUTXOs, err = tapdClient.ListUtxos( + ctxb, &taprpc.ListUtxosRequest{}, + ) + if err != nil { + return err + } + + if len(clientUTXOs.ManagedUtxos) != numUTXOs { + return fmt.Errorf("expected %v UTXO, got %d", numUTXOs, + len(clientUTXOs.ManagedUtxos)) + } + + return nil + }, defaultTimeout) + require.NoErrorf(t, err, "failed to assert UTXOs: %v, last state: %v", + err, clientUTXOs) + + return clientUTXOs +} + +func locateAssetTransfers(t *testing.T, tapdClient *tapClient, + txid chainhash.Hash) *taprpc.AssetTransfer { + + var transfer *taprpc.AssetTransfer + err := wait.NoError(func() error { + ctxb := context.Background() + forceCloseTransfer, err := tapdClient.ListTransfers( + ctxb, &taprpc.ListTransfersRequest{ + AnchorTxid: txid.String(), + }, + ) + if err != nil { + return fmt.Errorf("unable to list %v transfers: %w", + tapdClient.node.Name(), err) + } + if len(forceCloseTransfer.Transfers) != 1 { + return fmt.Errorf("%v is missing force close "+ + "transfer", tapdClient.node.Name()) + } + + 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) + + return transfer +} + +func connectAllNodes(t *testing.T, net *NetworkHarness, nodes []*HarnessNode) { + for i, node := range nodes { + for j := i + 1; j < len(nodes); j++ { + peer := nodes[j] + net.ConnectNodesPerm(t, node, peer) + } + } +} + +func fundAllNodes(t *testing.T, net *NetworkHarness, nodes []*HarnessNode) { + for _, node := range nodes { + net.SendCoins(t, btcutil.SatoshiPerBitcoin, node) + } +} + +func syncUniverses(t *testing.T, universe *tapClient, nodes ...*HarnessNode) { + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + for _, node := range nodes { + nodeTapClient := newTapClient(t, node) + + universeHostAddr := universe.node.Cfg.LitAddr() + t.Logf("Syncing node %v with universe %v", node.Cfg.Name, + universeHostAddr) + + itest.SyncUniverses( + ctxt, t, nodeTapClient, universe, universeHostAddr, + defaultTimeout, + ) + } +} + +func assertUniverseProofExists(t *testing.T, universe *tapClient, + assetID, groupKey, scriptKey []byte, outpoint string) *taprpc.Asset { + + t.Logf("Asserting proof outpoint=%v, script_key=%x", outpoint, + scriptKey) + + req := &universerpc.UniverseKey{ + Id: &universerpc.ID{ + ProofType: universerpc.ProofType_PROOF_TYPE_TRANSFER, + }, + LeafKey: &universerpc.AssetKey{ + Outpoint: &universerpc.AssetKey_OpStr{ + OpStr: outpoint, + }, + ScriptKey: &universerpc.AssetKey_ScriptKeyBytes{ + ScriptKeyBytes: scriptKey, + }, + }, + } + + switch { + case len(groupKey) > 0: + req.Id.Id = &universerpc.ID_GroupKey{ + GroupKey: groupKey, + } + + case len(assetID) > 0: + req.Id.Id = &universerpc.ID_AssetId{ + AssetId: assetID, + } + + default: + t.Fatalf("Need either asset ID or group key") + } + + ctxb := context.Background() + var proofResp *universerpc.AssetProofResponse + err := wait.NoError(func() error { + var pErr error + proofResp, pErr = universe.QueryProof(ctxb, req) + return pErr + }, defaultTimeout) + require.NoError( + t, err, "%v: outpoint=%v, script_key=%x", err, outpoint, + scriptKey, + ) + + if len(groupKey) > 0 { + require.NotNil(t, proofResp.AssetLeaf.Asset.AssetGroup) + require.Equal( + t, proofResp.AssetLeaf.Asset.AssetGroup.TweakedGroupKey, + groupKey, + ) + } else { + require.Equal( + t, proofResp.AssetLeaf.Asset.AssetGenesis.AssetId, + assetID, + ) + } + + a := proofResp.AssetLeaf.Asset + t.Logf("Proof found for scriptKey=%x, amount=%d", a.ScriptKey, a.Amount) + + return a +} + +func assertPendingChannels(t *testing.T, node *HarnessNode, + mintedAsset *taprpc.Asset, numChannels int, localSum, + remoteSum uint64) { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + pendingChannelsResp, err := node.PendingChannels( + ctxt, &lnrpc.PendingChannelsRequest{}, + ) + require.NoError(t, err) + require.Len(t, pendingChannelsResp.PendingOpenChannels, numChannels) + + pendingChan := pendingChannelsResp.PendingOpenChannels[0] + var pendingJSON rfqmsg.JsonAssetChannel + err = json.Unmarshal( + pendingChan.Channel.CustomChannelData, &pendingJSON, + ) + require.NoError(t, err) + require.Len(t, pendingJSON.Assets, 1) + + require.NotZero(t, pendingJSON.Assets[0].Capacity) + + // Check the decimal display of the channel funding blob. If no explicit + // value was set, we assume and expect the value of 0. + var expectedDecimalDisplay uint8 + if mintedAsset.DecimalDisplay != nil { + expectedDecimalDisplay = uint8( + mintedAsset.DecimalDisplay.DecimalDisplay, + ) + } + + require.Equal( + t, expectedDecimalDisplay, + pendingJSON.Assets[0].AssetInfo.DecimalDisplay, + ) + + // Check the balance of the pending channel. + assetID := mintedAsset.AssetGenesis.AssetId + pendingLocalBalance, pendingRemoteBalance, _, _ := + getAssetChannelBalance( + t, node, assetID, true, + ) + require.EqualValues(t, localSum, pendingLocalBalance) + require.EqualValues(t, remoteSum, pendingRemoteBalance) +} + +func assertAssetChan(t *testing.T, src, dst *HarnessNode, fundingAmount uint64, + mintedAsset *taprpc.Asset) { + + assetID := mintedAsset.AssetGenesis.AssetId + assetIDStr := hex.EncodeToString(assetID) + err := wait.NoError(func() error { + a, err := getChannelCustomData(src, dst) + if err != nil { + return err + } + + if a.AssetInfo.AssetGenesis.AssetID != assetIDStr { + return fmt.Errorf("expected asset ID %s, got %s", + assetIDStr, a.AssetInfo.AssetGenesis.AssetID) + } + if a.Capacity != fundingAmount { + return fmt.Errorf("expected capacity %d, got %d", + fundingAmount, a.Capacity) + } + + // Check the decimal display of the channel funding blob. If no + // explicit value was set, we assume and expect the value of 0. + var expectedDecimalDisplay uint8 + if mintedAsset.DecimalDisplay != nil { + expectedDecimalDisplay = uint8( + mintedAsset.DecimalDisplay.DecimalDisplay, + ) + } + + if a.AssetInfo.DecimalDisplay != expectedDecimalDisplay { + return fmt.Errorf("expected decimal display %d, got %d", + expectedDecimalDisplay, + a.AssetInfo.DecimalDisplay) + } + + return nil + }, defaultTimeout) + require.NoError(t, err) +} + +func assertChannelKnown(t *testing.T, node *HarnessNode, + chanPoint *lnrpc.ChannelPoint) { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + txid, err := chainhash.NewHash(chanPoint.GetFundingTxidBytes()) + require.NoError(t, err) + targetChanPoint := fmt.Sprintf( + "%v:%d", txid.String(), chanPoint.OutputIndex, + ) + + err = wait.NoError(func() error { + graphResp, err := node.DescribeGraph( + ctxt, &lnrpc.ChannelGraphRequest{}, + ) + if err != nil { + return err + } + + found := false + for _, edge := range graphResp.Edges { + if edge.ChanPoint == targetChanPoint { + found = true + break + } + } + + if !found { + return fmt.Errorf("channel %v not found", + targetChanPoint) + } + + return nil + }, defaultTimeout) + require.NoError(t, err) +} + +func getChannelCustomData(src, dst *HarnessNode) (*rfqmsg.JsonAssetChanInfo, + error) { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + srcDestChannels, err := src.ListChannels( + ctxt, &lnrpc.ListChannelsRequest{ + Peer: dst.PubKey[:], + }, + ) + if err != nil { + return nil, err + } + + assetChannels := fn.Filter(func(c *lnrpc.Channel) bool { + return len(c.CustomChannelData) > 0 + }, srcDestChannels.Channels) + + if len(assetChannels) != 1 { + return nil, fmt.Errorf("expected 1 asset channel, got %d: %v", + len(assetChannels), spew.Sdump(assetChannels)) + } + + targetChan := assetChannels[0] + + var assetData rfqmsg.JsonAssetChannel + err = json.Unmarshal(targetChan.CustomChannelData, &assetData) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal asset data: %w", + err) + } + + if len(assetData.Assets) != 1 { + return nil, fmt.Errorf("expected 1 asset, got %d", + len(assetData.Assets)) + } + + return &assetData.Assets[0], nil +} + +func getAssetChannelBalance(t *testing.T, node *HarnessNode, assetID []byte, + pending bool) (uint64, uint64, uint64, uint64) { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + balance, err := node.ChannelBalance( + ctxt, &lnrpc.ChannelBalanceRequest{}, + ) + require.NoError(t, err) + + var assetBalance rfqmsg.JsonAssetChannelBalances + err = json.Unmarshal(balance.CustomChannelData, &assetBalance) + require.NoError(t, err) + + balances := assetBalance.OpenChannels + if pending { + balances = assetBalance.PendingChannels + } + + var localSum, remoteSum uint64 + for assetIDString := range balances { + if assetIDString != hex.EncodeToString(assetID) { + continue + } + + localSum += balances[assetIDString].LocalBalance + remoteSum += balances[assetIDString].RemoteBalance + } + + return localSum, remoteSum, balance.LocalBalance.Sat, + balance.RemoteBalance.Sat +} + +func fetchChannel(t *testing.T, node *HarnessNode, + chanPoint *lnrpc.ChannelPoint) *lnrpc.Channel { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + channelResp, err := node.ListChannels(ctxt, &lnrpc.ListChannelsRequest{ + ActiveOnly: true, + }) + require.NoError(t, err) + + chanFundingHash, err := lnrpc.GetChanPointFundingTxid(chanPoint) + require.NoError(t, err) + + chanPointStr := fmt.Sprintf("%v:%v", chanFundingHash, + chanPoint.OutputIndex) + + var targetChan *lnrpc.Channel + for _, channel := range channelResp.Channels { + if channel.ChannelPoint == chanPointStr { + targetChan = channel + + break + } + } + require.NotNil(t, targetChan) + + return targetChan +} + +func assertChannelSatBalance(t *testing.T, node *HarnessNode, + chanPoint *lnrpc.ChannelPoint, local, remote int64) { + + targetChan := fetchChannel(t, node, chanPoint) + + require.InDelta(t, local, targetChan.LocalBalance, 1) + require.InDelta(t, remote, targetChan.RemoteBalance, 1) +} + +func assertChannelAssetBalance(t *testing.T, node *HarnessNode, + chanPoint *lnrpc.ChannelPoint, local, remote uint64) { + + targetChan := fetchChannel(t, node, chanPoint) + + var assetBalance rfqmsg.JsonAssetChannel + err := json.Unmarshal(targetChan.CustomChannelData, &assetBalance) + require.NoError(t, err) + + require.Len(t, assetBalance.Assets, 1) + + require.InDelta(t, local, assetBalance.Assets[0].LocalBalance, 1) + require.InDelta(t, remote, assetBalance.Assets[0].RemoteBalance, 1) +} + +// addRoutingFee adds the default routing fee (1 part per million fee rate plus +// 1000 milli-satoshi base fee) to the given milli-satoshi amount. +func addRoutingFee(amt lnwire.MilliSatoshi) lnwire.MilliSatoshi { + return amt + (amt / 1000_000) + 1000 +} + +func sendAssetKeySendPayment(t *testing.T, src, dst *HarnessNode, amt uint64, + assetID []byte, btcAmt fn.Option[int64], opts ...payOpt) { + + cfg := defaultPayConfig() + for _, opt := range opts { + opt(cfg) + } + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + srcTapd := newTapClient(t, src) + + // Read out the custom preimage for the keysend payment. + var preimage lntypes.Preimage + _, err := rand.Read(preimage[:]) + require.NoError(t, err) + + hash := preimage.Hash() + + // Set the preimage. If the user supplied a preimage with the data + // flag, the preimage that is set here will be overwritten later. + customRecords := make(map[uint64][]byte) + customRecords[record.KeySendType] = preimage[:] + + sendReq := &routerrpc.SendPaymentRequest{ + Dest: dst.PubKey[:], + Amt: btcAmt.UnwrapOr(500), + DestCustomRecords: customRecords, + PaymentHash: hash[:], + TimeoutSeconds: int32(PaymentTimeout.Seconds()), + } + + stream, err := srcTapd.SendPayment(ctxt, &tchrpc.SendPaymentRequest{ + AssetId: assetID, + AssetAmount: amt, + PaymentRequest: sendReq, + }) + require.NoError(t, err) + + result, err := getAssetPaymentResult(stream, false) + require.NoError(t, err) + if result.Status == lnrpc.Payment_FAILED { + t.Logf("Failure reason: %v", result.FailureReason) + } + require.Equal(t, cfg.payStatus, result.Status) + require.Equal(t, cfg.failureReason, result.FailureReason) +} + +func sendKeySendPayment(t *testing.T, src, dst *HarnessNode, + amt btcutil.Amount) { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + // Read out the custom preimage for the keysend payment. + var preimage lntypes.Preimage + _, err := rand.Read(preimage[:]) + require.NoError(t, err) + + hash := preimage.Hash() + + // Set the preimage. If the user supplied a preimage with the data + // flag, the preimage that is set here will be overwritten later. + customRecords := make(map[uint64][]byte) + customRecords[record.KeySendType] = preimage[:] + + req := &routerrpc.SendPaymentRequest{ + Dest: dst.PubKey[:], + Amt: int64(amt), + DestCustomRecords: customRecords, + PaymentHash: hash[:], + TimeoutSeconds: int32(PaymentTimeout.Seconds()), + } + + stream, err := src.RouterClient.SendPaymentV2(ctxt, req) + require.NoError(t, err) + + result, err := getPaymentResult(stream) + require.NoError(t, err) + require.Equal(t, lnrpc.Payment_SUCCEEDED, result.Status) +} + +func createAndPayNormalInvoiceWithBtc(t *testing.T, src, dst *HarnessNode, + amountSat btcutil.Amount) { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + expirySeconds := 10 + invoiceResp, err := dst.AddInvoice(ctxt, &lnrpc.Invoice{ + Value: int64(amountSat), + Memo: "normal invoice", + Expiry: int64(expirySeconds), + }) + require.NoError(t, err) + + payInvoiceWithSatoshi(t, src, invoiceResp) +} + +func createAndPayNormalInvoice(t *testing.T, src, rfqPeer, dst *HarnessNode, + amountSat btcutil.Amount, assetID []byte, opts ...payOpt) uint64 { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + expirySeconds := 10 + invoiceResp, err := dst.AddInvoice(ctxt, &lnrpc.Invoice{ + Value: int64(amountSat), + Memo: "normal invoice", + Expiry: int64(expirySeconds), + }) + require.NoError(t, err) + + numUnits, _ := payInvoiceWithAssets( + t, src, rfqPeer, invoiceResp.PaymentRequest, assetID, opts..., + ) + + return numUnits +} + +func payInvoiceWithSatoshi(t *testing.T, payer *HarnessNode, + invoice *lnrpc.AddInvoiceResponse, opts ...payOpt) { + + cfg := defaultPayConfig() + for _, opt := range opts { + opt(cfg) + } + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + sendReq := &routerrpc.SendPaymentRequest{ + PaymentRequest: invoice.PaymentRequest, + TimeoutSeconds: int32(PaymentTimeout.Seconds()), + MaxShardSizeMsat: 80_000_000, + FeeLimitMsat: 1_000_000, + } + stream, err := payer.RouterClient.SendPaymentV2(ctxt, sendReq) + require.NoError(t, err) + + result, err := getPaymentResult(stream) + if cfg.errSubStr != "" { + require.ErrorContains(t, err, cfg.errSubStr) + } else { + require.NoError(t, err) + require.Equal(t, cfg.payStatus, result.Status) + require.Equal(t, cfg.failureReason, result.FailureReason) + } +} + +func payInvoiceWithSatoshiLastHop(t *testing.T, payer *HarnessNode, + invoice *lnrpc.AddInvoiceResponse, hopPub []byte, + expectedStatus lnrpc.Payment_PaymentStatus) { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + routeRes, err := payer.RouterClient.BuildRoute( + ctxb, &routerrpc.BuildRouteRequest{ + AmtMsat: 17800, + FinalCltvDelta: 80, + PaymentAddr: invoice.PaymentAddr, + HopPubkeys: [][]byte{hopPub}, + }, + ) + require.NoError(t, err) + + res, err := payer.RouterClient.SendToRouteV2( + ctxt, &routerrpc.SendToRouteRequest{ + PaymentHash: invoice.RHash, + Route: routeRes.Route, + }, + ) + + switch expectedStatus { + case lnrpc.Payment_FAILED: + require.NoError(t, err) + require.Equal(t, lnrpc.HTLCAttempt_FAILED, res.Status) + require.Nil(t, res.Preimage) + + case lnrpc.Payment_SUCCEEDED: + require.NoError(t, err) + require.Equal(t, lnrpc.HTLCAttempt_SUCCEEDED, res.Status) + } +} + +type payConfig struct { + smallShards bool + errSubStr string + allowOverpay bool + feeLimit lnwire.MilliSatoshi + payStatus lnrpc.Payment_PaymentStatus + failureReason lnrpc.PaymentFailureReason + rfq fn.Option[rfqmsg.ID] +} + +func defaultPayConfig() *payConfig { + return &payConfig{ + smallShards: false, + errSubStr: "", + feeLimit: 1_000_000, + payStatus: lnrpc.Payment_SUCCEEDED, + failureReason: lnrpc.PaymentFailureReason_FAILURE_REASON_NONE, + } +} + +type payOpt func(*payConfig) + +func withSmallShards() payOpt { + return func(c *payConfig) { + c.smallShards = true + } +} + +func withPayErrSubStr(errSubStr string) payOpt { + return func(c *payConfig) { + c.errSubStr = errSubStr + } +} + +func withFailure(status lnrpc.Payment_PaymentStatus, + reason lnrpc.PaymentFailureReason) payOpt { + + return func(c *payConfig) { + c.payStatus = status + c.failureReason = reason + } +} + +func withRFQ(rfqID rfqmsg.ID) payOpt { + return func(c *payConfig) { + c.rfq = fn.Some(rfqID) + } +} + +func withFeeLimit(limit lnwire.MilliSatoshi) payOpt { + return func(c *payConfig) { + c.feeLimit = limit + } +} + +func withAllowOverpay() payOpt { + return func(c *payConfig) { + c.allowOverpay = true + } +} + +func payInvoiceWithAssets(t *testing.T, payer, rfqPeer *HarnessNode, + payReq string, assetID []byte, + opts ...payOpt) (uint64, rfqmath.BigIntFixedPoint) { + + cfg := defaultPayConfig() + for _, opt := range opts { + opt(cfg) + } + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + payerTapd := newTapClient(t, payer) + + decodedInvoice, err := payer.DecodePayReq(ctxt, &lnrpc.PayReqString{ + PayReq: payReq, + }) + require.NoError(t, err) + + sendReq := &routerrpc.SendPaymentRequest{ + PaymentRequest: payReq, + TimeoutSeconds: int32(PaymentTimeout.Seconds()), + FeeLimitMsat: int64(cfg.feeLimit), + } + + if cfg.smallShards { + sendReq.MaxShardSizeMsat = 80_000_000 + } + + var rfqBytes []byte + cfg.rfq.WhenSome(func(i rfqmsg.ID) { + rfqBytes = make([]byte, len(i[:])) + copy(rfqBytes, i[:]) + }) + + stream, err := payerTapd.SendPayment(ctxt, &tchrpc.SendPaymentRequest{ + AssetId: assetID, + PeerPubkey: rfqPeer.PubKey[:], + PaymentRequest: sendReq, + RfqId: rfqBytes, + AllowOverpay: cfg.allowOverpay, + }) + require.NoError(t, err) + + // If an error is returned by the RPC method (meaning the stream itself + // was established, no network or auth error), we expect the error to be + // returned on the first read on the stream. + if cfg.errSubStr != "" { + _, err := stream.Recv() + require.ErrorContains(t, err, cfg.errSubStr) + + return 0, rfqmath.BigIntFixedPoint{} + } + + var ( + numUnits uint64 + rateVal rfqmath.FixedPoint[rfqmath.BigInt] + ) + if cfg.rfq.IsNone() { + // We want to receive the accepted quote message first, so we + // know how many assets we're going to pay. + quoteMsg, err := stream.Recv() + require.NoError(t, err) + acceptedQuote := quoteMsg.GetAcceptedSellOrder() + require.NotNil(t, acceptedQuote) + + peerPubKey := acceptedQuote.Peer + require.Equal(t, peerPubKey, rfqPeer.PubKeyStr) + + rpcRate := acceptedQuote.BidAssetRate + rate, err := rfqrpc.UnmarshalFixedPoint(rpcRate) + require.NoError(t, err) + + rateVal = *rate + + t.Logf("Got quote for %v asset units per BTC", rate) + + amountMsat := lnwire.MilliSatoshi(decodedInvoice.NumMsat) + milliSatsFP := rfqmath.MilliSatoshiToUnits(amountMsat, *rate) + numUnits = milliSatsFP.ScaleTo(0).ToUint64() + msatPerUnit := float64(decodedInvoice.NumMsat) / + float64(numUnits) + t.Logf("Got quote for %v asset units at %3f msat/unit from "+ + "peer %s with SCID %d", numUnits, msatPerUnit, + peerPubKey, acceptedQuote.Scid) + } + + result, err := getAssetPaymentResult( + stream, cfg.payStatus == lnrpc.Payment_IN_FLIGHT, + ) + require.NoError(t, err) + require.Equal(t, cfg.payStatus, result.Status) + require.Equal(t, cfg.failureReason, result.FailureReason) + + return numUnits, rateVal +} + +type invoiceConfig struct { + errSubStr string +} + +func defaultInvoiceConfig() *invoiceConfig { + return &invoiceConfig{ + errSubStr: "", + } +} + +type invoiceOpt func(*invoiceConfig) + +func withInvoiceErrSubStr(errSubStr string) invoiceOpt { + return func(c *invoiceConfig) { + c.errSubStr = errSubStr + } +} + +func createAssetInvoice(t *testing.T, dstRfqPeer, dst *HarnessNode, + assetAmount uint64, assetID []byte, + opts ...invoiceOpt) *lnrpc.AddInvoiceResponse { + + cfg := defaultInvoiceConfig() + for _, opt := range opts { + opt(cfg) + } + + 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) + + 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, + }, + }) + if cfg.errSubStr != "" { + require.ErrorContains(t, err, cfg.errSubStr) + + return nil + } else { + 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) + + t.Logf("Got quote for %v asset units per BTC", rate) + + assetUnits := rfqmath.NewBigIntFixedPoint(assetAmount, 0) + numMSats := rfqmath.UnitsToMilliSatoshi(assetUnits, *rate) + mSatPerUnit := float64(decodedInvoice.NumMsat) / float64(assetAmount) + + require.EqualValues(t, numMSats, decodedInvoice.NumMsat) + + t.Logf("Got quote for %d mSats at %3f msat/unit from peer %x with "+ + "SCID %d", decodedInvoice.NumMsat, mSatPerUnit, + dstRfqPeer.PubKey[:], resp.AcceptedBuyQuote.Scid) + + return resp.InvoiceResult +} + +// assertInvoiceHtlcAssets makes sure the invoice with the given hash shows the +// individual HTLCs that arrived for it and that they show the correct asset +// amounts for the given ID when decoded. +func assertInvoiceHtlcAssets(t *testing.T, node *HarnessNode, + addedInvoice *lnrpc.AddInvoiceResponse, assetID []byte, + assetAmount uint64) { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + invoice, err := node.InvoicesClient.LookupInvoiceV2( + ctxt, &invoicesrpc.LookupInvoiceMsg{ + InvoiceRef: &invoicesrpc.LookupInvoiceMsg_PaymentAddr{ + PaymentAddr: addedInvoice.PaymentAddr, + }, + }, + ) + require.NoError(t, err) + require.NotEmpty(t, invoice.Htlcs) + + t.Logf("Asset invoice: %v", toProtoJSON(t, invoice)) + + targetID := hex.EncodeToString(assetID) + + var totalAssetAmount uint64 + for _, htlc := range invoice.Htlcs { + require.NotEmpty(t, htlc.CustomChannelData) + + jsonHtlc := &rfqmsg.JsonHtlc{} + err := json.Unmarshal(htlc.CustomChannelData, jsonHtlc) + require.NoError(t, err) + + for _, balance := range jsonHtlc.Balances { + if balance.AssetID != targetID { + continue + } + + totalAssetAmount += balance.Amount + } + } + + // Due to rounding we allow up to 1 unit of error. + require.InDelta(t, assetAmount, totalAssetAmount, 1) +} + +// assertPaymentHtlcAssets makes sure the payment with the given hash shows the +// individual HTLCs that arrived for it and that they show the correct asset +// amounts for the given ID when decoded. +func assertPaymentHtlcAssets(t *testing.T, node *HarnessNode, payHash []byte, + assetID []byte, assetAmount uint64) { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + stream, err := node.RouterClient.TrackPaymentV2( + ctxt, &routerrpc.TrackPaymentRequest{ + PaymentHash: payHash, + NoInflightUpdates: true, + }, + ) + require.NoError(t, err) + + payment, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, payment) + require.NotEmpty(t, payment.Htlcs) + + t.Logf("Asset payment: %v", toProtoJSON(t, payment)) + + targetID := hex.EncodeToString(assetID) + + var totalAssetAmount uint64 + for _, htlc := range payment.Htlcs { + require.NotNil(t, htlc.Route) + require.NotEmpty(t, htlc.Route.CustomChannelData) + + jsonHtlc := &rfqmsg.JsonHtlc{} + err := json.Unmarshal(htlc.Route.CustomChannelData, jsonHtlc) + require.NoError(t, err) + + for _, balance := range jsonHtlc.Balances { + if balance.AssetID != targetID { + continue + } + + totalAssetAmount += balance.Amount + } + } + + // Due to rounding we allow up to 1 unit of error. + 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) { + + t.Helper() + + for { + sendEvent, err := sendEvents.Recv() + require.NoError(t, err) + + t.Logf("Received send event: %v", sendEvent.SendState) + if sendEvent.SendState == expectedState.String() { + return + } + } +} + +// coOpCloseBalanceCheck is a function type that can be passed into +// closeAssetChannelAndAsset to asset the final balance of the closing +// transaction. +type coOpCloseBalanceCheck func(t *testing.T, local, remote *HarnessNode, + closeTx *wire.MsgTx, closeUpdate *lnrpc.ChannelCloseUpdate, + assetID, groupKey []byte, universeTap *tapClient) + +// noOpCoOpCloseBalanceCheck is a no-op implementation of the co-op close +// balance check that can be used in tests. +func noOpCoOpCloseBalanceCheck(_ *testing.T, _, _ *HarnessNode, _ *wire.MsgTx, + _ *lnrpc.ChannelCloseUpdate, _, _ []byte, _ *tapClient) { + + // This is a no-op function. +} + +// closeAssetChannelAndAssert closes the channel between the local and remote +// node and asserts the final balances of the closing transaction. +func closeAssetChannelAndAssert(t *harnessTest, net *NetworkHarness, + local, remote *HarnessNode, chanPoint *lnrpc.ChannelPoint, + assetID, groupKey []byte, universeTap *tapClient, + balanceCheck coOpCloseBalanceCheck) { + + t.t.Helper() + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + closeStream, _, err := t.lndHarness.CloseChannel( + local, chanPoint, false, + ) + require.NoError(t.t, err) + + localTapd := newTapClient(t.t, local) + sendEvents, err := localTapd.SubscribeSendEvents( + ctxt, &taprpc.SubscribeSendEventsRequest{}, + ) + require.NoError(t.t, err) + + mineBlocks(t, net, 1, 1) + + closeUpdate, err := t.lndHarness.WaitForChannelClose(closeStream) + require.NoError(t.t, err) + + closeTxid, err := chainhash.NewHash(closeUpdate.ClosingTxid) + require.NoError(t.t, err) + + closeTransaction := t.lndHarness.Miner.GetRawTransaction(*closeTxid) + closeTx := closeTransaction.MsgTx() + t.Logf("Channel closed with txid: %v", closeTxid) + t.Logf("Close transaction: %v", spew.Sdump(closeTx)) + + waitForSendEvent(t.t, sendEvents, tapfreighter.SendStateComplete) + + // Check the final balance of the closing transaction. + balanceCheck( + t.t, local, remote, closeTx, closeUpdate, assetID, groupKey, + universeTap, + ) +} + +// assertDefaultCoOpCloseBalance returns a default implementation of the co-op +// close balance check that can be used in tests. It assumes the initiator has +// both an asset and BTC balance left, while the responder's balance can be +// specified with the boolean variables. +func assertDefaultCoOpCloseBalance(remoteBtcBalance, + remoteAssetBalance bool) coOpCloseBalanceCheck { + + return func(t *testing.T, local, remote *HarnessNode, + closeTx *wire.MsgTx, closeUpdate *lnrpc.ChannelCloseUpdate, + assetID, groupKey []byte, universeTap *tapClient) { + + defaultCoOpCloseBalanceCheck( + t, local, remote, closeTx, closeUpdate, assetID, + groupKey, universeTap, remoteBtcBalance, + remoteAssetBalance, + ) + } +} + +// defaultCoOpCloseBalanceCheck is a default implementation of the co-op close +// balance check that can be used in tests. It assumes the initiator has both +// an asset and BTC balance left, while the responder's balance can be specified +// with the boolean variables. +func defaultCoOpCloseBalanceCheck(t *testing.T, local, remote *HarnessNode, + closeTx *wire.MsgTx, closeUpdate *lnrpc.ChannelCloseUpdate, + assetID, groupKey []byte, universeTap *tapClient, remoteBtcBalance, + remoteAssetBalance bool) { + + // With the channel closed, we'll now assert that the co-op close + // transaction was inserted into the local universe. + // + // We expect that at most four outputs exist: one for the local asset + // output, one for the remote asset output, one for the remote BTC + // channel balance and one for the remote BTC channel balance. + // + // Those outputs are only present if the respective party has a + // non-dust balance. + numOutputs := 2 + additionalOutputs := 1 + if remoteBtcBalance { + numOutputs++ + } + if remoteAssetBalance { + numOutputs++ + additionalOutputs++ + } + + closeTxid := closeTx.TxHash() + require.Len(t, closeTx.TxOut, numOutputs) + + outIdx := 0 + dummyAmt := int64(1000) + require.LessOrEqual(t, closeTx.TxOut[outIdx].Value, dummyAmt) + + if remoteAssetBalance { + outIdx++ + require.LessOrEqual(t, closeTx.TxOut[outIdx].Value, dummyAmt) + } + + // We also require there to be at most two additional outputs, one for + // each of the asset outputs with balance. + require.Len(t, closeUpdate.AdditionalOutputs, additionalOutputs) + + var remoteCloseOut *lnrpc.CloseOutput + if remoteBtcBalance { + // The remote node has received a couple of HTLCs with an above + // dust value, so it should also have accumulated a non-dust + // balance, even after subtracting 1k sats for the asset output. + remoteCloseOut = closeUpdate.RemoteCloseOutput + require.NotNil(t, remoteCloseOut) + + outIdx++ + require.EqualValues( + t, remoteCloseOut.AmountSat-dummyAmt, + closeTx.TxOut[outIdx].Value, + ) + } else if remoteAssetBalance { + // The remote node has received a couple of HTLCs but not enough + // to go above dust. So it should still have an asset balance + // that we can verify. + remoteCloseOut = closeUpdate.RemoteCloseOutput + require.NotNil(t, remoteCloseOut) + } + + // The local node should have received the local BTC balance minus the + // TX fees and 1k sats for the asset output. + localCloseOut := closeUpdate.LocalCloseOutput + require.NotNil(t, localCloseOut) + outIdx++ + require.Greater( + t, closeTx.TxOut[outIdx].Value, + localCloseOut.AmountSat-dummyAmt, + ) + + // Find out which of the additional outputs is the local one and which + // is the remote. + localAuxOut := closeUpdate.AdditionalOutputs[0] + + var remoteAuxOut *lnrpc.CloseOutput + if remoteAssetBalance { + remoteAuxOut = closeUpdate.AdditionalOutputs[1] + } + if !localAuxOut.IsLocal && remoteAuxOut != nil { + localAuxOut, remoteAuxOut = remoteAuxOut, localAuxOut + } + + // The first two transaction outputs should be the additional outputs + // as identified by the pk scripts in the close update. + localAssetIndex, remoteAssetIndex := 1, 0 + if bytes.Equal(closeTx.TxOut[0].PkScript, localAuxOut.PkScript) { + localAssetIndex, remoteAssetIndex = 0, 1 + } + + if remoteAuxOut != nil { + require.Equal( + t, remoteAuxOut.PkScript, + closeTx.TxOut[remoteAssetIndex].PkScript, + ) + } + + require.Equal( + t, localAuxOut.PkScript, + closeTx.TxOut[localAssetIndex].PkScript, + ) + + // We now verify the arrival of the local balance asset proof at the + // universe server. + var localAssetCloseOut rfqmsg.JsonCloseOutput + err := json.Unmarshal( + localCloseOut.CustomChannelData, &localAssetCloseOut, + ) + require.NoError(t, err) + + for assetIDStr, scriptKeyStr := range localAssetCloseOut.ScriptKeys { + scriptKeyBytes, err := hex.DecodeString(scriptKeyStr) + require.NoError(t, err) + + require.Equal(t, hex.EncodeToString(assetID), assetIDStr) + + a := assertUniverseProofExists( + t, universeTap, assetID, groupKey, scriptKeyBytes, + fmt.Sprintf("%v:%v", closeTxid, localAssetIndex), + ) + + localTapd := newTapClient(t, local) + + scriptKey, err := btcec.ParsePubKey(scriptKeyBytes) + require.NoError(t, err) + assertAssetExists( + t, localTapd, assetID, a.Amount, scriptKey, true, + true, false, + ) + } + + // If there is no remote asset balance, we're done. + if !remoteAssetBalance { + return + } + + // At this point the remote close output should be defined, otherwise + // something went wrong. + require.NotNil(t, remoteCloseOut) + + // And then we verify the arrival of the remote balance asset proof at + // the universe server as well. + var remoteAssetCloseOut rfqmsg.JsonCloseOutput + err = json.Unmarshal( + remoteCloseOut.CustomChannelData, &remoteAssetCloseOut, + ) + require.NoError(t, err) + + for assetIDStr, scriptKeyStr := range remoteAssetCloseOut.ScriptKeys { + scriptKeyBytes, err := hex.DecodeString(scriptKeyStr) + require.NoError(t, err) + + require.Equal(t, hex.EncodeToString(assetID), assetIDStr) + + a := assertUniverseProofExists( + t, universeTap, assetID, groupKey, scriptKeyBytes, + fmt.Sprintf("%v:%v", closeTxid, remoteAssetIndex), + ) + + remoteTapd := newTapClient(t, remote) + + scriptKey, err := btcec.ParsePubKey(scriptKeyBytes) + require.NoError(t, err) + assertAssetExists( + t, remoteTapd, assetID, a.Amount, scriptKey, true, + true, false, + ) + } +} + +// initiatorZeroAssetBalanceCoOpBalanceCheck is a co-op close balance check +// function that can be used when the initiator has a zero asset balance. +func initiatorZeroAssetBalanceCoOpBalanceCheck(t *testing.T, _, + remote *HarnessNode, closeTx *wire.MsgTx, + closeUpdate *lnrpc.ChannelCloseUpdate, assetID, groupKey []byte, + universeTap *tapClient) { + + // With the channel closed, we'll now assert that the co-op close + // transaction was inserted into the local universe. + // + // Since the initiator has a zero asset balance, we expect that at most + // three outputs exist: one for the remote asset output, one for the + // remote BTC channel balance and one for the initiator's BTC channel + // balance (which cannot be zero or below dust due to the mandatory + // channel reserve). + numOutputs := 3 + + closeTxid := closeTx.TxHash() + require.Len(t, closeTx.TxOut, numOutputs) + + // We assume that the local node has a non-zero BTC balance left. + localOut, _ := closeTxOut(t, closeTx, closeUpdate, true) + require.Greater(t, localOut.Value, int64(1000)) + + // We also require there to be exactly one additional output, which is + // the remote asset output. + require.Len(t, closeUpdate.AdditionalOutputs, 1) + assetTxOut, assetOutputIndex := findTxOut( + t, closeTx, closeUpdate.AdditionalOutputs[0].PkScript, + ) + require.LessOrEqual(t, assetTxOut.Value, int64(1000)) + + // The remote node has received a couple of HTLCs with an above + // dust value, so it should also have accumulated a non-dust + // balance, even after subtracting 1k sats for the asset output. + remoteCloseOut := closeUpdate.RemoteCloseOutput + require.NotNil(t, remoteCloseOut) + + // Find out which of the additional outputs is the local one and which + // is the remote. + remoteAuxOut := closeUpdate.AdditionalOutputs[0] + require.False(t, remoteAuxOut.IsLocal) + + // And then we verify the arrival of the remote balance asset proof at + // the universe server as well. + var remoteAssetCloseOut rfqmsg.JsonCloseOutput + err := json.Unmarshal( + remoteCloseOut.CustomChannelData, &remoteAssetCloseOut, + ) + require.NoError(t, err) + + for assetIDStr, scriptKeyStr := range remoteAssetCloseOut.ScriptKeys { + scriptKeyBytes, err := hex.DecodeString(scriptKeyStr) + require.NoError(t, err) + + require.Equal(t, hex.EncodeToString(assetID), assetIDStr) + + a := assertUniverseProofExists( + t, universeTap, assetID, groupKey, scriptKeyBytes, + fmt.Sprintf("%v:%v", closeTxid, assetOutputIndex), + ) + + remoteTapd := newTapClient(t, remote) + + scriptKey, err := btcec.ParsePubKey(scriptKeyBytes) + require.NoError(t, err) + assertAssetExists( + t, remoteTapd, assetID, a.Amount, scriptKey, true, + true, false, + ) + } +} + +// closeTxOut returns either the local or remote output from the close +// transaction, based on the information given in the close update. +func closeTxOut(t *testing.T, closeTx *wire.MsgTx, + closeUpdate *lnrpc.ChannelCloseUpdate, local bool) (*wire.TxOut, int) { + + var targetPkScript []byte + if local { + require.NotNil(t, closeUpdate.LocalCloseOutput) + targetPkScript = closeUpdate.LocalCloseOutput.PkScript + } else { + require.NotNil(t, closeUpdate.RemoteCloseOutput) + targetPkScript = closeUpdate.RemoteCloseOutput.PkScript + } + + return findTxOut(t, closeTx, targetPkScript) +} + +// findTxOut returns the transaction output with the target pk script from the +// given transaction. +func findTxOut(t *testing.T, tx *wire.MsgTx, targetPkScript []byte) ( + *wire.TxOut, int) { + + for i, txOut := range tx.TxOut { + if bytes.Equal(txOut.PkScript, targetPkScript) { + return txOut, i + } + } + + t.Fatalf("close output (targetPkScript=%x) not found in close "+ + "transaction", targetPkScript) + + return &wire.TxOut{}, 0 +} + +type tapClient struct { + node *HarnessNode + lnd *rpc.HarnessRPC + taprpc.TaprootAssetsClient + assetwalletrpc.AssetWalletClient + tapdevrpc.TapDevClient + mintrpc.MintClient + rfqrpc.RfqClient + tchrpc.TaprootAssetChannelsClient + universerpc.UniverseClient +} + +func newTapClient(t *testing.T, node *HarnessNode) *tapClient { + cfg := node.Cfg + superMacFile := bakeSuperMacaroon(t, cfg, getLiTMacFromFile, false) + + t.Cleanup(func() { + require.NoError(t, os.Remove(superMacFile)) + }) + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultTimeout) + defer cancel() + + rawConn, err := connectRPCWithMac( + ctxt, cfg.LitAddr(), cfg.LitTLSCertPath, superMacFile, + ) + require.NoError(t, err) + + t.Cleanup(func() { + _ = rawConn.Close() + }) + + assetsClient := taprpc.NewTaprootAssetsClient(rawConn) + assetWalletClient := assetwalletrpc.NewAssetWalletClient(rawConn) + devClient := tapdevrpc.NewTapDevClient(rawConn) + mintMintClient := mintrpc.NewMintClient(rawConn) + rfqClient := rfqrpc.NewRfqClient(rawConn) + tchClient := tchrpc.NewTaprootAssetChannelsClient(rawConn) + universeClient := universerpc.NewUniverseClient(rawConn) + + return &tapClient{ + node: node, + TaprootAssetsClient: assetsClient, + AssetWalletClient: assetWalletClient, + TapDevClient: devClient, + MintClient: mintMintClient, + RfqClient: rfqClient, + TaprootAssetChannelsClient: tchClient, + UniverseClient: universeClient, + } +} + +func connectRPCWithMac(ctx context.Context, hostPort, tlsCertPath, + macFilePath string) (*grpc.ClientConn, error) { + + tlsCreds, err := credentials.NewClientTLSFromFile(tlsCertPath, "") + if err != nil { + return nil, err + } + + opts := []grpc.DialOption{ + grpc.WithBlock(), + grpc.WithTransportCredentials(tlsCreds), + } + + macOption, err := readMacaroon(macFilePath) + if err != nil { + return nil, err + } + + opts = append(opts, macOption) + + return grpc.DialContext(ctx, hostPort, opts...) +} + +func assertAssetBalance(t *testing.T, client *tapClient, assetID []byte, + expectedBalance uint64) { + + t.Helper() + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, shortTimeout) + defer cancel() + + req := &taprpc.ListBalancesRequest{ + GroupBy: &taprpc.ListBalancesRequest_AssetId{ + AssetId: true, + }, + } + + err := wait.NoError(func() error { + assetIDBalances, err := client.ListBalances(ctxt, req) + if err != nil { + return err + } + + assetIDFound := false + for _, balance := range assetIDBalances.AssetBalances { + if !bytes.Equal(balance.AssetGenesis.AssetId, assetID) { + continue + } + + assetIDFound = true + if expectedBalance != balance.Balance { + return fmt.Errorf("expected balance %d, got %d", + expectedBalance, balance.Balance) + } + } + + if expectedBalance > 0 && !assetIDFound { + return fmt.Errorf("expected balance %d, got 0", + expectedBalance) + } + 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) + } +} + +// 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) + } +} + +func assertNumAssetOutputs(t *testing.T, client *tapClient, assetID []byte, + numPieces int) { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, shortTimeout) + defer cancel() + + resp, err := client.ListAssets(ctxt, &taprpc.ListAssetRequest{ + IncludeLeased: true, + }) + require.NoError(t, err) + + var outputs []*taprpc.Asset + for _, a := range resp.Assets { + if !bytes.Equal(a.AssetGenesis.AssetId, assetID) { + continue + } + + outputs = append(outputs, a) + } + + require.Len(t, outputs, numPieces) +} + +func assertAssetExists(t *testing.T, client *tapClient, assetID []byte, + amount uint64, scriptKey *btcec.PublicKey, scriptKeyLocal, + scriptKeyKnown, scriptKeyHasScript bool) *taprpc.Asset { + + t.Helper() + + var a *taprpc.Asset + err := wait.NoError(func() error { + var err error + a, err = assetExists( + t, client, assetID, amount, scriptKey, scriptKeyLocal, + scriptKeyKnown, scriptKeyHasScript, + ) + return err + }, shortTimeout) + require.NoError(t, err) + + return a +} + +func assetExists(t *testing.T, client *tapClient, assetID []byte, + amount uint64, scriptKey *btcec.PublicKey, scriptKeyLocal, + scriptKeyKnown, scriptKeyHasScript bool) (*taprpc.Asset, error) { + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, shortTimeout) + defer cancel() + + resp, err := client.ListAssets(ctxt, &taprpc.ListAssetRequest{ + IncludeLeased: true, + }) + if err != nil { + return nil, err + } + + for _, a := range resp.Assets { + if !bytes.Equal(a.AssetGenesis.AssetId, assetID) { + continue + } + + if amount != a.Amount { + continue + } + + if scriptKey != nil { + xOnlyKey, _ := schnorr.ParsePubKey( + schnorr.SerializePubKey(scriptKey), + ) + xOnlyKeyBytes := xOnlyKey.SerializeCompressed() + if !bytes.Equal(xOnlyKeyBytes, a.ScriptKey) { + continue + } + } + + if scriptKeyLocal != a.ScriptKeyIsLocal { + continue + } + + if scriptKeyKnown != a.ScriptKeyDeclaredKnown { + continue + } + + if scriptKeyHasScript != a.ScriptKeyHasScriptPath { + continue + } + + // Success, we have found the asset we're looking for. + return a, nil + } + + return nil, fmt.Errorf("asset with given criteria (amount=%d) not "+ + "found in list, got: %v", amount, toProtoJSON(t, resp)) +} + +func logBalance(t *testing.T, nodes []*HarnessNode, assetID []byte, + occasion string) { + + t.Helper() + + time.Sleep(time.Millisecond * 250) + + for _, node := range nodes { + local, remote, localSat, remoteSat := + getAssetChannelBalance(t, node, assetID, false) + + t.Logf("%-7s balance: local=%-9d remote=%-9d, localSat=%-9d, "+ + "remoteSat=%-9d (%v)", node.Cfg.Name, local, remote, + localSat, remoteSat, occasion) + } +} + +// readMacaroon tries to read the macaroon file at the specified path and create +// gRPC dial options from it. +func readMacaroon(macPath string) (grpc.DialOption, error) { + // Load the specified macaroon file. + macBytes, err := os.ReadFile(macPath) + if err != nil { + return nil, fmt.Errorf("unable to read macaroon path : %w", err) + } + + return macFromBytes(macBytes) +} + +// macFromBytes returns a macaroon from the given byte slice. +func macFromBytes(macBytes []byte) (grpc.DialOption, error) { + mac := &macaroon.Macaroon{} + if err := mac.UnmarshalBinary(macBytes); err != nil { + return nil, fmt.Errorf("unable to decode macaroon: %w", err) + } + + // Now we append the macaroon credentials to the dial options. + cred, err := macaroons.NewMacaroonCredential(mac) + if err != nil { + return nil, fmt.Errorf("error creating macaroon credential: %w", + err) + } + return grpc.WithPerRPCCredentials(cred), nil +} + +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 f1677c25..b678723f 100644 --- a/itest/litd_accounts_test.go +++ b/itest/litd_accounts_test.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/lightninglabs/lightning-terminal/litrpc" + "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/lntest" @@ -433,3 +434,44 @@ func getPaymentResult(stream routerrpc.Router_SendPaymentV2Client) ( } } } + +func getAssetPaymentResult( + 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 + // with "insufficient local balance"... ¯\_(ツ)_/¯ + // Probably something weird within lnd itself. + time.Sleep(time.Second) + + for { + msg, err := s.Recv() + if err != nil { + return nil, err + } + + // Ignore RFQ quote acceptance messages read from the send + // payment stream, as they are not relevant. + quote := msg.GetAcceptedSellOrder() + if quote != nil { + continue + } + + payment := msg.GetPaymentResult() + if payment == nil { + return nil, fmt.Errorf("unexpected message: %v", msg) + } + + // 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 new file mode 100644 index 00000000..b7c9e059 --- /dev/null +++ b/itest/litd_custom_channels_test.go @@ -0,0 +1,3807 @@ +package itest + +import ( + "bytes" + "context" + "fmt" + "math" + "math/big" + "slices" + "time" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/itest" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/rfqmath" + "github.com/lightninglabs/taproot-assets/rfqmsg" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightninglabs/taproot-assets/taprpc/mintrpc" + oraclerpc "github.com/lightninglabs/taproot-assets/taprpc/priceoraclerpc" + "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" + "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc" + tchrpc "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc" + "github.com/lightninglabs/taproot-assets/taprpc/universerpc" + "github.com/lightninglabs/taproot-assets/tapscript" + "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" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +var ( + dummyMetaData = &taprpc.AssetMeta{ + Data: []byte("some metadata"), + } + + itestAsset = &mintrpc.MintAsset{ + AssetType: taprpc.AssetType_NORMAL, + Name: "itest-asset-cents", + AssetMeta: dummyMetaData, + Amount: 1_000_000, + } + + shortTimeout = time.Second * 5 +) + +var ( + lndArgsTemplate = []string{ + "--trickledelay=50", + "--gossip.sub-batch-delay=5ms", + "--caches.rpc-graph-cache-duration=100ms", + "--default-remote-max-htlcs=483", + "--dust-threshold=5000000", + "--rpcmiddleware.enable", + "--protocol.anchors", + "--protocol.option-scid-alias", + "--protocol.zero-conf", + "--protocol.simple-taproot-chans", + "--protocol.simple-taproot-overlay-chans", + "--protocol.custom-message=17", + "--accept-keysend", + "--debuglevel=trace,GRPC=error,BTCN=info", + } + litdArgsTemplateNoOracle = []string{ + "--taproot-assets.allow-public-uni-proof-courier", + "--taproot-assets.universe.public-access=rw", + "--taproot-assets.universe.sync-all-assets", + "--taproot-assets.universerpccourier.skipinitdelay", + "--taproot-assets.universerpccourier.backoffresetwait=1s", + "--taproot-assets.universerpccourier.numtries=5", + "--taproot-assets.universerpccourier.initialbackoff=300ms", + "--taproot-assets.universerpccourier.maxbackoff=600ms", + "--taproot-assets.universerpccourier.skipinitdelay", + "--taproot-assets.universerpccourier.backoffresetwait=100ms", + "--taproot-assets.universerpccourier.initialbackoff=300ms", + "--taproot-assets.universerpccourier.maxbackoff=600ms", + "--taproot-assets.custodianproofretrievaldelay=500ms", + } + litdArgsTemplate = append(litdArgsTemplateNoOracle, []string{ + "--taproot-assets.experimental.rfq.priceoracleaddress=" + + "use_mock_price_oracle_service_promise_to_" + + "not_use_on_mainnet", + "--taproot-assets.experimental.rfq.mockoracleassetsperbtc=" + + "5820600", + }...) +) + +const ( + fundingAmount = 50_000 + startAmount = fundingAmount * 2 +) + +// testCustomChannelsLarge tests that we can create a network with custom +// channels and send large asset payments over them. +func testCustomChannelsLarge(_ context.Context, net *NetworkHarness, + t *harnessTest) { + + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplate) + + // Explicitly set the proof courier as Zane (now has no other role + // other than proof shuffling), otherwise a hashmail courier will be + // used. For the funding transaction, we're just posting it and don't + // expect a true receiver. + 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(), + )) + + // The topology we are going for looks like the following: + // + // Charlie --[assets]--> Dave --[sats]--> Erin --[assets]--> Fabia + // | + // | + // [assets] + // | + // v + // Yara + // + // With [assets] being a custom channel and [sats] being a normal, BTC + // only channel. + // All 5 nodes need to be full litd nodes running in integrated mode + // with tapd included. We also need specific flags to be enabled, so we + // create 5 completely new nodes, ignoring the two default nodes that + // are created by the harness. + charlie, err := net.NewNode( + t.t, "Charlie", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + dave, err := net.NewNode(t.t, "Dave", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + erin, err := net.NewNode(t.t, "Erin", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + fabia, err := net.NewNode( + t.t, "Fabia", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + yara, err := net.NewNode( + t.t, "Yara", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + nodes := []*HarnessNode{charlie, dave, erin, fabia, yara} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + // Create the normal channel between Dave and Erin. + t.Logf("Opening normal channel between Dave and Erin...") + channelOp := openChannelAndAssert( + t, net, dave, erin, lntest.OpenChannelParams{ + Amt: 10_000_000, + SatPerVByte: 5, + }, + ) + defer closeChannelAndAssert(t, net, dave, channelOp, false) + + // This is the only public channel, we need everyone to be aware of it. + assertChannelKnown(t.t, charlie, channelOp) + assertChannelKnown(t.t, fabia, channelOp) + + universeTap := newTapClient(t.t, zane) + charlieTap := newTapClient(t.t, charlie) + daveTap := newTapClient(t.t, dave) + erinTap := newTapClient(t.t, erin) + fabiaTap := newTapClient(t.t, fabia) + yaraTap := newTapClient(t.t, yara) + + // Mint an asset on Charlie and sync all nodes to Charlie as the + // universe. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, charlieTap, + []*mintrpc.MintAssetRequest{ + { + Asset: itestAsset, + }, + }, + ) + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + + t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) + syncUniverses(t.t, charlieTap, dave, erin, fabia, yara) + t.Logf("Universes synced between all nodes, distributing assets...") + + const ( + daveFundingAmount = uint64(400_000) + erinFundingAmount = uint64(200_000) + ) + charlieFundingAmount := cents.Amount - uint64(2*400_000) + + chanPointCD, _, _ := createTestAssetNetwork( + t, net, charlieTap, daveTap, erinTap, fabiaTap, yaraTap, + universeTap, cents, 400_000, charlieFundingAmount, + daveFundingAmount, erinFundingAmount, DefaultPushSat, + ) + + // 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(charlie, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, charlie)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, yara)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(yara, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(erin, fabia)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(fabia, erin)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(charlie, erin)) + + // Print initial channel balances. + logBalance(t.t, nodes, assetID, "initial") + + // Try larger invoice payments, first from Charlie to Fabia, then half + // of the amount back in the other direction. + const fabiaInvoiceAssetAmount = 20_000 + invoiceResp := createAssetInvoice( + t.t, erin, fabia, fabiaInvoiceAssetAmount, assetID, + ) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, + ) + logBalance(t.t, nodes, assetID, "after invoice") + + invoiceResp2 := createAssetInvoice( + t.t, dave, charlie, fabiaInvoiceAssetAmount/2, assetID, + ) + + // Sleep for a second to make sure the balances fully propagated before + // we make the payment. Otherwise, we'll make an RFQ order with a max + // amount of zero. + time.Sleep(time.Second * 1) + + payInvoiceWithAssets( + t.t, fabia, erin, invoiceResp2.PaymentRequest, assetID, + ) + logBalance(t.t, nodes, assetID, "after invoice 2") + + // Now we send a large invoice from Charlie to Dave. + const largeInvoiceAmount = 100_000 + invoiceResp3 := createAssetInvoice( + t.t, charlie, dave, largeInvoiceAmount, assetID, + ) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp3.PaymentRequest, assetID, + ) + logBalance(t.t, nodes, assetID, "after invoice 3") + + // Make sure the invoice on the receiver side and the payment on the + // sender side show the individual HTLCs that arrived for it and that + // they show the correct asset amounts when decoded. + assertInvoiceHtlcAssets( + t.t, dave, invoiceResp3, assetID, largeInvoiceAmount, + ) + assertPaymentHtlcAssets( + t.t, charlie, invoiceResp3.RHash, assetID, largeInvoiceAmount, + ) + + // We keysend the rest, so that all the balance is on Dave's side. + charlieRemainingBalance := charlieFundingAmount - largeInvoiceAmount - + fabiaInvoiceAssetAmount/2 + sendAssetKeySendPayment( + t.t, charlie, dave, charlieRemainingBalance, + assetID, fn.None[int64](), + ) + logBalance(t.t, nodes, assetID, "after keysend") + + // And now we close the channel to test how things look if all the + // balance is on the non-initiator (recipient) side. + t.Logf("Closing Charlie -> Dave channel") + closeAssetChannelAndAssert( + t, net, charlie, dave, chanPointCD, assetID, nil, + universeTap, initiatorZeroAssetBalanceCoOpBalanceCheck, + ) +} + +// testCustomChannels tests that we can create a network with custom channels +// and send asset payments over them. +func testCustomChannels(_ context.Context, net *NetworkHarness, + t *harnessTest) { + + ctxb := context.Background() + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplate) + + // Explicitly set the proof courier as Zane (now has no other role + // other than proof shuffling), otherwise a hashmail courier will be + // used. For the funding transaction, we're just posting it and don't + // expect a true receiver. + 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(), + )) + + // The topology we are going for looks like the following: + // + // Charlie --[assets]--> Dave --[sats]--> Erin --[assets]--> Fabia + // | + // | + // [assets] + // | + // v + // Yara + // + // With [assets] being a custom channel and [sats] being a normal, BTC + // only channel. + // All 5 nodes need to be full litd nodes running in integrated mode + // with tapd included. We also need specific flags to be enabled, so we + // create 5 completely new nodes, ignoring the two default nodes that + // are created by the harness. + charlie, err := net.NewNode( + t.t, "Charlie", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + dave, err := net.NewNode(t.t, "Dave", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + erin, err := net.NewNode(t.t, "Erin", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + fabia, err := net.NewNode( + t.t, "Fabia", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + yara, err := net.NewNode( + t.t, "Yara", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + nodes := []*HarnessNode{charlie, dave, erin, fabia, yara} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + // Create the normal channel between Dave and Erin. + t.Logf("Opening normal channel between Dave and Erin...") + channelOp := openChannelAndAssert( + t, net, dave, erin, lntest.OpenChannelParams{ + Amt: 5_000_000, + SatPerVByte: 5, + }, + ) + defer closeChannelAndAssert(t, net, dave, channelOp, false) + + // This is the only public channel, we need everyone to be aware of it. + assertChannelKnown(t.t, charlie, channelOp) + assertChannelKnown(t.t, fabia, channelOp) + + universeTap := newTapClient(t.t, zane) + charlieTap := newTapClient(t.t, charlie) + daveTap := newTapClient(t.t, dave) + erinTap := newTapClient(t.t, erin) + fabiaTap := newTapClient(t.t, fabia) + yaraTap := newTapClient(t.t, yara) + + // Mint an asset on Charlie and sync all nodes to Charlie as the + // universe. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, charlieTap, + []*mintrpc.MintAssetRequest{ + { + Asset: itestAsset, + }, + }, + ) + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + fundingScriptTree := tapscript.NewChannelFundingScriptTree() + fundingScriptKey := fundingScriptTree.TaprootKey + fundingScriptTreeBytes := fundingScriptKey.SerializeCompressed() + + t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) + syncUniverses(t.t, charlieTap, dave, erin, fabia, yara) + t.Logf("Universes synced between all nodes, distributing assets...") + + const ( + daveFundingAmount = uint64(startAmount) + erinFundingAmount = uint64(fundingAmount) + ) + charlieFundingAmount := cents.Amount - 2*startAmount + + chanPointCD, chanPointDY, chanPointEF := createTestAssetNetwork( + t, net, charlieTap, daveTap, erinTap, fabiaTap, yaraTap, + universeTap, cents, startAmount, charlieFundingAmount, + daveFundingAmount, erinFundingAmount, DefaultPushSat, + ) + + // We'll be tracking the expected asset balances throughout the test, so + // we can assert it after each action. + charlieAssetBalance := charlieFundingAmount + daveAssetBalance := uint64(startAmount) + erinAssetBalance := uint64(startAmount) + fabiaAssetBalance := uint64(0) + yaraAssetBalance := uint64(0) + + // 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(charlie, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, charlie)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, yara)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(yara, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(erin, fabia)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(fabia, erin)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(charlie, erin)) + + // Print initial channel balances. + logBalance(t.t, nodes, assetID, "initial") + + // ------------ + // Test case 1: Send a direct keysend payment from Charlie to Dave, + // sending the whole balance. + // ------------ + keySendAmount := charlieFundingAmount + sendAssetKeySendPayment( + t.t, charlie, dave, charlieFundingAmount, assetID, + fn.None[int64](), + ) + logBalance(t.t, nodes, assetID, "after keysend") + + charlieAssetBalance -= keySendAmount + daveAssetBalance += keySendAmount + + // We should be able to send 1000 assets back immediately, because + // there is enough on-chain balance on Dave's side to be able to create + // an HTLC. We use an invoice to execute another code path. + const charlieInvoiceAmount = 1_000 + invoiceResp := createAssetInvoice( + t.t, dave, charlie, charlieInvoiceAmount, assetID, + ) + payInvoiceWithAssets( + t.t, dave, charlie, invoiceResp.PaymentRequest, assetID, + withSmallShards(), + ) + logBalance(t.t, nodes, assetID, "after invoice back") + + // Make sure the invoice on the receiver side and the payment on the + // sender side show the individual HTLCs that arrived for it and that + // they show the correct asset amounts when decoded. + assertInvoiceHtlcAssets( + t.t, charlie, invoiceResp, assetID, charlieInvoiceAmount, + ) + assertPaymentHtlcAssets( + t.t, dave, invoiceResp.RHash, assetID, charlieInvoiceAmount, + ) + + charlieAssetBalance += charlieInvoiceAmount + daveAssetBalance -= charlieInvoiceAmount + + // We should also be able to do a non-asset (BTC only) keysend payment + // from Charlie to Dave. This'll also replenish the BTC balance of + // Dave, making it possible to send another asset HTLC below, sending + // all assets back to Charlie (so we have enough balance for further + // tests). + sendKeySendPayment(t.t, charlie, dave, 2000) + logBalance(t.t, nodes, assetID, "after BTC only keysend") + + // Let's keysend the rest of the balance back to Charlie. + sendAssetKeySendPayment( + t.t, dave, charlie, charlieFundingAmount-charlieInvoiceAmount, + assetID, fn.None[int64](), + ) + logBalance(t.t, nodes, assetID, "after keysend back") + + charlieAssetBalance += charlieFundingAmount - charlieInvoiceAmount + daveAssetBalance -= charlieFundingAmount - charlieInvoiceAmount + + // ------------ + // Test case 2: Pay a normal invoice from Dave by Charlie, making it + // a direct channel invoice payment with no RFQ SCID present in the + // invoice. + // ------------ + createAndPayNormalInvoice( + t.t, charlie, dave, dave, 20_000, assetID, withSmallShards(), + withFailure(lnrpc.Payment_FAILED, failureIncorrectDetails), + ) + logBalance(t.t, nodes, assetID, "after invoice") + + // We should also be able to do a multi-hop BTC only payment, paying an + // invoice from Erin by Charlie. + createAndPayNormalInvoiceWithBtc(t.t, charlie, erin, 2000) + logBalance(t.t, nodes, assetID, "after BTC only invoice") + + // ------------ + // Test case 3: Pay an asset invoice from Dave by Charlie, making it + // a direct channel invoice payment with an RFQ SCID present in the + // invoice. + // ------------ + const daveInvoiceAssetAmount = 2_000 + invoiceResp = createAssetInvoice( + t.t, charlie, dave, daveInvoiceAssetAmount, assetID, + ) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, + withSmallShards(), + ) + logBalance(t.t, nodes, assetID, "after invoice") + + charlieAssetBalance -= daveInvoiceAssetAmount + daveAssetBalance += daveInvoiceAssetAmount + + // ------------ + // Test case 3.5: Pay an asset invoice from Dave by Charlie with normal + // satoshi payment flow. We expect that payment to fail, since it's a + // direct channel payment and the invoice is for assets, not sats. So + // without a conversion, it is rejected by the receiver. + // ------------ + invoiceResp = createAssetInvoice( + t.t, charlie, dave, daveInvoiceAssetAmount, assetID, + ) + payInvoiceWithSatoshi( + t.t, charlie, invoiceResp, withFailure( + lnrpc.Payment_FAILED, failureIncorrectDetails, + ), + ) + logBalance(t.t, nodes, assetID, "after asset invoice paid with sats") + + // We don't need to update the asset balances of Charlie and Dave here + // as the invoice payment failed. + + // ------------ + // Test case 4: Pay a normal invoice from Erin by Charlie. + // ------------ + paidAssetAmount := createAndPayNormalInvoice( + t.t, charlie, dave, erin, 20_000, assetID, withSmallShards(), + ) + logBalance(t.t, nodes, assetID, "after invoice") + + charlieAssetBalance -= paidAssetAmount + daveAssetBalance += paidAssetAmount + + // ------------ + // Test case 5: Create an asset invoice on Fabia and pay it from + // Charlie. + // ------------ + const fabiaInvoiceAssetAmount1 = 1000 + invoiceResp = createAssetInvoice( + t.t, erin, fabia, fabiaInvoiceAssetAmount1, assetID, + ) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, + withSmallShards(), + ) + logBalance(t.t, nodes, assetID, "after invoice") + + charlieAssetBalance -= fabiaInvoiceAssetAmount1 + daveAssetBalance += fabiaInvoiceAssetAmount1 + erinAssetBalance -= fabiaInvoiceAssetAmount1 + fabiaAssetBalance += fabiaInvoiceAssetAmount1 + + // ------------ + // Test case 6: Create an asset invoice on Fabia and pay it with just + // BTC from Dave, making sure it ends up being a multipart payment (we + // set the maximum shard size to 80k sat and 15k asset units will be + // more than a single shard). + // ------------ + const fabiaInvoiceAssetAmount2 = 15_000 + invoiceResp = createAssetInvoice( + t.t, erin, fabia, fabiaInvoiceAssetAmount2, assetID, + ) + payInvoiceWithSatoshi(t.t, dave, invoiceResp) + logBalance(t.t, nodes, assetID, "after invoice") + + erinAssetBalance -= fabiaInvoiceAssetAmount2 + fabiaAssetBalance += fabiaInvoiceAssetAmount2 + + // ------------ + // Test case 7: Create an asset invoice on Fabia and pay it with assets + // from Charlie, making sure it ends up being a multipart payment as + // well, with the high amount of asset units to send and the hard coded + // 80k sat max shard size. + // ------------ + const fabiaInvoiceAssetAmount3 = 10_000 + invoiceResp = createAssetInvoice( + t.t, erin, fabia, fabiaInvoiceAssetAmount3, assetID, + ) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, + withSmallShards(), + ) + logBalance(t.t, nodes, assetID, "after invoice") + + charlieAssetBalance -= fabiaInvoiceAssetAmount3 + daveAssetBalance += fabiaInvoiceAssetAmount3 + erinAssetBalance -= fabiaInvoiceAssetAmount3 + fabiaAssetBalance += fabiaInvoiceAssetAmount3 + + // ------------ + // Test case 8: An invoice payment over two channels that are both asset + // channels. + // ------------ + logBalance(t.t, nodes, assetID, "before asset-to-asset") + + const yaraInvoiceAssetAmount1 = 1000 + invoiceResp = createAssetInvoice( + t.t, dave, yara, yaraInvoiceAssetAmount1, assetID, + ) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, + withSmallShards(), + ) + logBalance(t.t, nodes, assetID, "after asset-to-asset") + + charlieAssetBalance -= yaraInvoiceAssetAmount1 + yaraAssetBalance += yaraInvoiceAssetAmount1 + + // ------------ + // Test case 8: Now we'll close each of the channels, starting with the + // Charlie -> Dave custom channel. + // ------------ + t.Logf("Closing Charlie -> Dave channel") + closeAssetChannelAndAssert( + t, net, charlie, dave, chanPointCD, assetID, nil, + universeTap, assertDefaultCoOpCloseBalance(true, true), + ) + + t.Logf("Closing Dave -> Yara channel, close initiated by Yara") + closeAssetChannelAndAssert( + t, net, yara, dave, chanPointDY, assetID, nil, + universeTap, assertDefaultCoOpCloseBalance(false, true), + ) + + t.Logf("Closing Erin -> Fabia channel") + closeAssetChannelAndAssert( + t, net, erin, fabia, chanPointEF, assetID, nil, + universeTap, assertDefaultCoOpCloseBalance(true, true), + ) + + // We've been tracking the off-chain channel balances all this time, so + // now that we have the assets on-chain again, we can assert them. Due + // to rounding errors that happened when sending multiple shards with + // MPP, we need to do some slight adjustments. + charlieAssetBalance += 1 + erinAssetBalance += 4 + fabiaAssetBalance -= 4 + yaraAssetBalance -= 1 + assertAssetBalance(t.t, charlieTap, assetID, charlieAssetBalance) + assertAssetBalance(t.t, daveTap, assetID, daveAssetBalance) + assertAssetBalance(t.t, erinTap, assetID, erinAssetBalance) + assertAssetBalance(t.t, fabiaTap, assetID, fabiaAssetBalance) + assertAssetBalance(t.t, yaraTap, assetID, yaraAssetBalance) + + // ------------ + // Test case 10: We now open a new asset channel and close it again, to + // make sure that a non-existent remote balance is handled correctly. + t.Logf("Opening new asset channel between Charlie and Dave...") + fundRespCD, err := charlieTap.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: fundingAmount, + AssetId: assetID, + PeerPubkey: dave.PubKey[:], + FeeRateSatPerVbyte: 5, + }, + ) + require.NoError(t.t, err) + t.Logf("Funded second channel between Charlie and Dave: %v", fundRespCD) + + mineBlocks(t, net, 6, 1) + + // Assert that the proofs for both channels has been uploaded to the + // designated Universe server. + assertUniverseProofExists( + t.t, universeTap, assetID, nil, fundingScriptTreeBytes, + fmt.Sprintf("%v:%v", fundRespCD.Txid, fundRespCD.OutputIndex), + ) + assertAssetChan(t.t, charlie, dave, fundingAmount, cents) + + // And let's just close the channel again. + chanPointCD = &lnrpc.ChannelPoint{ + OutputIndex: uint32(fundRespCD.OutputIndex), + FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: fundRespCD.Txid, + }, + } + + t.Logf("Closing Charlie -> Dave channel") + closeAssetChannelAndAssert( + t, net, charlie, dave, chanPointCD, assetID, nil, + universeTap, assertDefaultCoOpCloseBalance(false, false), + ) + + // Charlie should still have four asset pieces, two with the same size. + assertNumAssetOutputs(t.t, charlieTap, assetID, 2) + assertAssetExists( + t.t, charlieTap, assetID, charlieAssetBalance-fundingAmount, + nil, true, false, false, + ) + assertAssetExists( + t.t, charlieTap, assetID, fundingAmount, nil, true, true, + false, + ) + + // Dave should have two outputs, one from the initial channel with Yara + // and one from the remaining amount of the channel with Charlie. + assertNumAssetOutputs(t.t, daveTap, assetID, 2) + daveFirstChannelRemainder := daveFundingAmount - + yaraInvoiceAssetAmount1 + 1 + assertAssetExists( + t.t, daveTap, assetID, daveFirstChannelRemainder, nil, true, + true, false, + ) + assertAssetExists( + t.t, daveTap, assetID, + daveAssetBalance-daveFirstChannelRemainder, nil, true, true, + false, + ) + + // Fabia and Yara should all have a single output each, just what was + // left over from the initial channel. + assertNumAssetOutputs(t.t, fabiaTap, assetID, 1) + assertAssetExists( + t.t, fabiaTap, assetID, fabiaAssetBalance, nil, true, true, + false, + ) + assertNumAssetOutputs(t.t, yaraTap, assetID, 1) + assertAssetExists( + t.t, yaraTap, assetID, yaraAssetBalance, nil, true, true, false, + ) + + // Erin didn't use all of his assets when opening the channel, so he + // should have two outputs, the change from the channel opening and the + // remaining amount after closing the channel. + assertNumAssetOutputs(t.t, erinTap, assetID, 2) + erinChange := startAmount - erinFundingAmount + assertAssetExists( + t.t, erinTap, assetID, erinAssetBalance-erinChange, nil, true, + true, false, + ) + assertAssetExists( + t.t, erinTap, assetID, erinChange, nil, true, false, false, + ) + + // The asset balances should still remain unchanged. + assertAssetBalance(t.t, charlieTap, assetID, charlieAssetBalance) + assertAssetBalance(t.t, daveTap, assetID, daveAssetBalance) + assertAssetBalance(t.t, erinTap, assetID, erinAssetBalance) + assertAssetBalance(t.t, fabiaTap, assetID, fabiaAssetBalance) +} + +// testCustomChannelsGroupedAsset tests that we can create a network with custom +// channels that use grouped assets and send asset payments over them. +func testCustomChannelsGroupedAsset(_ context.Context, net *NetworkHarness, + t *harnessTest) { + + ctxb := context.Background() + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplate) + + // Explicitly set the proof courier as Zane (now has no other role + // other than proof shuffling), otherwise a hashmail courier will be + // used. For the funding transaction, we're just posting it and don't + // expect a true receiver. + 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(), + )) + + // The topology we are going for looks like the following: + // + // Charlie --[assets]--> Dave --[sats]--> Erin --[assets]--> Fabia + // | + // | + // [assets] + // | + // v + // Yara + // + // With [assets] being a custom channel and [sats] being a normal, BTC + // only channel. + // All 5 nodes need to be full litd nodes running in integrated mode + // with tapd included. We also need specific flags to be enabled, so we + // create 5 completely new nodes, ignoring the two default nodes that + // are created by the harness. + charlie, err := net.NewNode( + t.t, "Charlie", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + dave, err := net.NewNode(t.t, "Dave", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + erin, err := net.NewNode(t.t, "Erin", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + fabia, err := net.NewNode( + t.t, "Fabia", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + yara, err := net.NewNode( + t.t, "Yara", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + nodes := []*HarnessNode{charlie, dave, erin, fabia, yara} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + // Create the normal channel between Dave and Erin. + t.Logf("Opening normal channel between Dave and Erin...") + channelOp := openChannelAndAssert( + t, net, dave, erin, lntest.OpenChannelParams{ + Amt: 5_000_000, + SatPerVByte: 5, + }, + ) + defer closeChannelAndAssert(t, net, dave, channelOp, false) + + // This is the only public channel, we need everyone to be aware of it. + assertChannelKnown(t.t, charlie, channelOp) + assertChannelKnown(t.t, fabia, channelOp) + + universeTap := newTapClient(t.t, zane) + charlieTap := newTapClient(t.t, charlie) + daveTap := newTapClient(t.t, dave) + erinTap := newTapClient(t.t, erin) + fabiaTap := newTapClient(t.t, fabia) + yaraTap := newTapClient(t.t, yara) + + groupAssetReq := itest.CopyRequest(&mintrpc.MintAssetRequest{ + Asset: itestAsset, + }) + groupAssetReq.Asset.NewGroupedAsset = true + + // Mint an asset on Charlie and sync all nodes to Charlie as the + // universe. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, charlieTap, + []*mintrpc.MintAssetRequest{groupAssetReq}, + ) + + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + groupID := cents.GetAssetGroup().GetTweakedGroupKey() + fundingScriptTree := tapscript.NewChannelFundingScriptTree() + fundingScriptKey := fundingScriptTree.TaprootKey + fundingScriptTreeBytes := fundingScriptKey.SerializeCompressed() + + t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) + syncUniverses(t.t, charlieTap, dave, erin, fabia, yara) + t.Logf("Universes synced between all nodes, distributing assets...") + + const ( + daveFundingAmount = uint64(startAmount) + erinFundingAmount = uint64(fundingAmount) + ) + charlieFundingAmount := cents.Amount - 2*startAmount + + chanPointCD, chanPointDY, chanPointEF := createTestAssetNetwork( + t, net, charlieTap, daveTap, erinTap, fabiaTap, yaraTap, + universeTap, cents, startAmount, charlieFundingAmount, + daveFundingAmount, erinFundingAmount, DefaultPushSat, + ) + + // We'll be tracking the expected asset balances throughout the test, so + // we can assert it after each action. + charlieAssetBalance := charlieFundingAmount + daveAssetBalance := uint64(startAmount) + erinAssetBalance := uint64(startAmount) + fabiaAssetBalance := uint64(0) + yaraAssetBalance := uint64(0) + + // 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(charlie, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, charlie)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, yara)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(yara, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(erin, fabia)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(fabia, erin)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(charlie, erin)) + + // Print initial channel balances. + logBalance(t.t, nodes, assetID, "initial") + + // ------------ + // Test case 1: Send a direct keysend payment from Charlie to Dave. + // ------------ + const keySendAmount = 100 + sendAssetKeySendPayment( + t.t, charlie, dave, keySendAmount, assetID, fn.None[int64](), + ) + logBalance(t.t, nodes, assetID, "after keysend") + + charlieAssetBalance -= keySendAmount + daveAssetBalance += keySendAmount + + // We should be able to send the 100 assets back immediately, because + // there is enough on-chain balance on Dave's side to be able to create + // an HTLC. + sendAssetKeySendPayment( + t.t, dave, charlie, keySendAmount, assetID, fn.None[int64](), + ) + logBalance(t.t, nodes, assetID, "after keysend back") + + charlieAssetBalance += keySendAmount + daveAssetBalance -= keySendAmount + + // We should also be able to do a non-asset (BTC only) keysend payment. + sendKeySendPayment(t.t, charlie, dave, 2000) + logBalance(t.t, nodes, assetID, "after BTC only keysend") + + // ------------ + // Test case 2: Pay a normal invoice from Dave by Charlie, making it + // a direct channel invoice payment with no RFQ SCID present in the + // invoice. + // ------------ + createAndPayNormalInvoice( + t.t, charlie, dave, dave, 20_000, assetID, withSmallShards(), + withFailure(lnrpc.Payment_FAILED, failureIncorrectDetails), + ) + logBalance(t.t, nodes, assetID, "after invoice") + + // We should also be able to do a multi-hop BTC only payment, paying an + // invoice from Erin by Charlie. + createAndPayNormalInvoiceWithBtc(t.t, charlie, erin, 2000) + logBalance(t.t, nodes, assetID, "after BTC only invoice") + + // ------------ + // Test case 3: Pay an asset invoice from Dave by Charlie, making it + // a direct channel invoice payment with an RFQ SCID present in the + // invoice. + // ------------ + const daveInvoiceAssetAmount = 2_000 + invoiceResp := createAssetInvoice( + t.t, charlie, dave, daveInvoiceAssetAmount, assetID, + ) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, + withSmallShards(), + ) + logBalance(t.t, nodes, assetID, "after invoice") + + // Make sure the invoice on the receiver side and the payment on the + // sender side show the individual HTLCs that arrived for it and that + // they show the correct asset amounts when decoded. + assertInvoiceHtlcAssets( + t.t, dave, invoiceResp, assetID, daveInvoiceAssetAmount, + ) + assertPaymentHtlcAssets( + t.t, charlie, invoiceResp.RHash, assetID, + daveInvoiceAssetAmount, + ) + + charlieAssetBalance -= daveInvoiceAssetAmount + daveAssetBalance += daveInvoiceAssetAmount + + // ------------ + // Test case 4: Pay a normal invoice from Erin by Charlie. + // ------------ + paidAssetAmount := createAndPayNormalInvoice( + t.t, charlie, dave, erin, 20_000, assetID, withSmallShards(), + ) + logBalance(t.t, nodes, assetID, "after invoice") + + charlieAssetBalance -= paidAssetAmount + daveAssetBalance += paidAssetAmount + + // ------------ + // Test case 5: Create an asset invoice on Fabia and pay it from + // Charlie. + // ------------ + const fabiaInvoiceAssetAmount1 = 1000 + invoiceResp = createAssetInvoice( + t.t, erin, fabia, fabiaInvoiceAssetAmount1, assetID, + ) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, + withSmallShards(), + ) + logBalance(t.t, nodes, assetID, "after invoice") + + charlieAssetBalance -= fabiaInvoiceAssetAmount1 + daveAssetBalance += fabiaInvoiceAssetAmount1 + erinAssetBalance -= fabiaInvoiceAssetAmount1 + fabiaAssetBalance += fabiaInvoiceAssetAmount1 + + // ------------ + // Test case 6: Create an asset invoice on Fabia and pay it with just + // BTC from Dave, making sure it ends up being a multipart payment (we + // set the maximum shard size to 80k sat and 15k asset units will be + // more than a single shard). + // ------------ + const fabiaInvoiceAssetAmount2 = 15_000 + invoiceResp = createAssetInvoice( + t.t, erin, fabia, fabiaInvoiceAssetAmount2, assetID, + ) + payInvoiceWithSatoshi(t.t, dave, invoiceResp) + logBalance(t.t, nodes, assetID, "after invoice") + + erinAssetBalance -= fabiaInvoiceAssetAmount2 + fabiaAssetBalance += fabiaInvoiceAssetAmount2 + + // ------------ + // Test case 7: Create an asset invoice on Fabia and pay it with assets + // from Charlie, making sure it ends up being a multipart payment as + // well, with the high amount of asset units to send and the hard coded + // 80k sat max shard size. + // ------------ + const fabiaInvoiceAssetAmount3 = 10_000 + invoiceResp = createAssetInvoice( + t.t, erin, fabia, fabiaInvoiceAssetAmount3, assetID, + ) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, + withSmallShards(), + ) + logBalance(t.t, nodes, assetID, "after invoice") + + charlieAssetBalance -= fabiaInvoiceAssetAmount3 + daveAssetBalance += fabiaInvoiceAssetAmount3 + erinAssetBalance -= fabiaInvoiceAssetAmount3 + fabiaAssetBalance += fabiaInvoiceAssetAmount3 + + // ------------ + // Test case 8: An invoice payment over two channels that are both asset + // channels. + // ------------ + logBalance(t.t, nodes, assetID, "before asset-to-asset") + + const yaraInvoiceAssetAmount1 = 1000 + invoiceResp = createAssetInvoice( + t.t, dave, yara, yaraInvoiceAssetAmount1, assetID, + ) + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, + withSmallShards(), + ) + logBalance(t.t, nodes, assetID, "after asset-to-asset") + + charlieAssetBalance -= yaraInvoiceAssetAmount1 + yaraAssetBalance += yaraInvoiceAssetAmount1 + + // ------------ + // Test case 8: Now we'll close each of the channels, starting with the + // Charlie -> Dave custom channel. + // ------------ + t.Logf("Closing Charlie -> Dave channel") + closeAssetChannelAndAssert( + t, net, charlie, dave, chanPointCD, assetID, groupID, + universeTap, assertDefaultCoOpCloseBalance(true, true), + ) + + t.Logf("Closing Dave -> Yara channel, close initiated by Yara") + closeAssetChannelAndAssert( + t, net, yara, dave, chanPointDY, assetID, groupID, + universeTap, assertDefaultCoOpCloseBalance(false, true), + ) + + t.Logf("Closing Erin -> Fabia channel") + closeAssetChannelAndAssert( + t, net, erin, fabia, chanPointEF, assetID, groupID, + universeTap, assertDefaultCoOpCloseBalance(true, true), + ) + + // We've been tracking the off-chain channel balances all this time, so + // now that we have the assets on-chain again, we can assert them. Due + // to rounding errors that happened when sending multiple shards with + // MPP, we need to do some slight adjustments. + charlieAssetBalance += 2 + daveAssetBalance -= 1 + erinAssetBalance += 4 + fabiaAssetBalance -= 4 + yaraAssetBalance -= 1 + assertAssetBalance(t.t, charlieTap, assetID, charlieAssetBalance) + assertAssetBalance(t.t, daveTap, assetID, daveAssetBalance) + assertAssetBalance(t.t, erinTap, assetID, erinAssetBalance) + assertAssetBalance(t.t, fabiaTap, assetID, fabiaAssetBalance) + assertAssetBalance(t.t, yaraTap, assetID, yaraAssetBalance) + + // ------------ + // Test case 10: We now open a new asset channel and close it again, to + // make sure that a non-existent remote balance is handled correctly. + t.Logf("Opening new asset channel between Charlie and Dave...") + fundRespCD, err := charlieTap.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: fundingAmount, + AssetId: assetID, + PeerPubkey: dave.PubKey[:], + FeeRateSatPerVbyte: 5, + }, + ) + require.NoError(t.t, err) + t.Logf("Funded second channel between Charlie and Dave: %v", fundRespCD) + + mineBlocks(t, net, 6, 1) + + // Assert that the proofs for both channels has been uploaded to the + // designated Universe server. + assertUniverseProofExists( + t.t, universeTap, nil, groupID, fundingScriptTreeBytes, + fmt.Sprintf("%v:%v", fundRespCD.Txid, fundRespCD.OutputIndex), + ) + assertAssetChan(t.t, charlie, dave, fundingAmount, cents) + + // And let's just close the channel again. + chanPointCD = &lnrpc.ChannelPoint{ + OutputIndex: uint32(fundRespCD.OutputIndex), + FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: fundRespCD.Txid, + }, + } + + t.Logf("Closing Charlie -> Dave channel") + closeAssetChannelAndAssert( + t, net, charlie, dave, chanPointCD, assetID, groupID, + universeTap, assertDefaultCoOpCloseBalance(false, false), + ) + + // Charlie should still have four asset pieces, two with the same size. + assertAssetExists( + t.t, charlieTap, assetID, charlieAssetBalance-fundingAmount, + nil, true, false, false, + ) + assertAssetExists( + t.t, charlieTap, assetID, fundingAmount, nil, true, true, + false, + ) + + // Charlie should have asset outputs: the leftover change from the + // channel funding, and the new close output. + assertNumAssetOutputs(t.t, charlieTap, assetID, 2) + + // The asset balances should still remain unchanged. + assertAssetBalance(t.t, charlieTap, assetID, charlieAssetBalance) + assertAssetBalance(t.t, daveTap, assetID, daveAssetBalance) + assertAssetBalance(t.t, erinTap, assetID, erinAssetBalance) + assertAssetBalance(t.t, fabiaTap, assetID, fabiaAssetBalance) +} + +// testCustomChannelsForceClose tests a force close scenario after both parties +// have an active asset balance. +func testCustomChannelsForceClose(_ context.Context, net *NetworkHarness, + t *harnessTest) { + + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplate) + + // Explicitly set the proof courier as Zane (now has no other role + // other than proof shuffling), otherwise a hashmail courier will be + // used. For the funding transaction, we're just posting it and don't + // expect a true receiver. + zane, err := net.NewNode( + t.t, "Zane", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + // For our litd args, make sure that they all seen Zane as the main + // Universe server. + litdArgs = append(litdArgs, fmt.Sprintf( + "--taproot-assets.proofcourieraddr=%s://%s", + proof.UniverseRpcCourierType, zane.Cfg.LitAddr(), + )) + + // For this simple test, we'll just have Carol -> Dave as an assets + // channel. + charlie, err := net.NewNode( + t.t, "Charlie", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + dave, err := net.NewNode(t.t, "Dave", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + + // Next we'll connect all the nodes and also fund them with some coins. + nodes := []*HarnessNode{charlie, dave} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + universeTap := newTapClient(t.t, zane) + charlieTap := newTapClient(t.t, charlie) + daveTap := newTapClient(t.t, dave) + + ctxb := context.Background() + + // Now we'll make an asset for Charlie that we'll use in the test to + // open a channel. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, charlieTap, + []*mintrpc.MintAssetRequest{ + { + Asset: itestAsset, + }, + }, + ) + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + + t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) + syncUniverses(t.t, charlieTap, dave) + t.Logf("Universes synced between all nodes, distributing assets...") + + // Before we actually create the asset channel, we want to make sure + // that failed attempts of creating a channel (e.g. due to insufficient + // on-chain funds) are cleaned up properly on the recipient side. + // We do this by sending all of Charlie's coins to a burn address then + // just sending him 50k sats, which isn't enough to fund a channel. + _, err = charlie.LightningClient.SendCoins( + ctxb, &lnrpc.SendCoinsRequest{ + Addr: burnAddr, + SendAll: true, + MinConfs: 0, + SpendUnconfirmed: true, + }, + ) + require.NoError(t.t, err) + net.SendCoins(t.t, 50_000, charlie) + + // The attempt should fail. But the recipient should receive the error, + // clean up the state and allow Charlie to try again after acquiring + // more funds. + _, err = charlieTap.FundChannel(ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: fundingAmount, + AssetId: assetID, + PeerPubkey: dave.PubKey[:], + FeeRateSatPerVbyte: 5, + }) + require.ErrorContains(t.t, err, "not enough witness outputs to create") + + // Now we'll fund the channel with the correct amount. + net.SendCoins(t.t, btcutil.SatoshiPerBitcoin, charlie) + + // Next we can open an asset channel from Charlie -> Dave, then kick + // off the main scenario. + t.Logf("Opening asset channels...") + assetFundResp, err := charlieTap.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: fundingAmount, + AssetId: assetID, + PeerPubkey: dave.PubKey[:], + FeeRateSatPerVbyte: 5, + }, + ) + require.NoError(t.t, err) + t.Logf("Funded channel between Charlie and Dave: %v", assetFundResp) + + // With the channel open, mine a block to confirm it. + mineBlocks(t, net, 6, 1) + + // A transfer for the funding transaction should be found in Charlie's + // DB. + fundingTxid, err := chainhash.NewHashFromStr(assetFundResp.Txid) + require.NoError(t.t, err) + assetFundingTransfer := locateAssetTransfers( + t.t, charlieTap, *fundingTxid, + ) + + t.Logf("Channel funding transfer: %v", + toProtoJSON(t.t, assetFundingTransfer)) + + // Charlie's balance should reflect that the funding asset is now + // excluded from balance reporting by tapd. + assertAssetBalance( + t.t, charlieTap, assetID, itestAsset.Amount-fundingAmount, + ) + + // Make sure that Charlie properly uploaded funding proof to the + // Universe server. + fundingScriptTree := tapscript.NewChannelFundingScriptTree() + fundingScriptKey := fundingScriptTree.TaprootKey + fundingScriptTreeBytes := fundingScriptKey.SerializeCompressed() + assertUniverseProofExists( + t.t, universeTap, assetID, nil, fundingScriptTreeBytes, + fmt.Sprintf( + "%v:%v", assetFundResp.Txid, assetFundResp.OutputIndex, + ), + ) + + // Make sure the channel shows the correct asset information. + assertAssetChan(t.t, charlie, dave, fundingAmount, cents) + + // 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(charlie, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, charlie)) + + // We'll also have dave sync with Charlie+Zane to ensure he has the + // proof for the funding output. We sync the transfers as well so he + // has all the proofs needed. + mode := universerpc.UniverseSyncMode_SYNC_FULL + diff, err := daveTap.SyncUniverse(ctxb, &universerpc.SyncRequest{ + UniverseHost: zane.Cfg.LitAddr(), + SyncMode: mode, + }) + require.NoError(t.t, err) + + t.Logf("Synced Dave w/ Zane, universe_diff=%v", toProtoJSON(t.t, diff)) + + // With the channel confirmed, we'll push over some keysend payments + // from Carol to Dave. We'll send over a bit more BTC each time so Dave + // will go to chain sweep his output (default fee rate is 50 sat/vb). + const ( + numPayments = 5 + keySendAmount = 100 + btcAmt = int64(5_000) + ) + for i := 0; i < numPayments; i++ { + sendAssetKeySendPayment( + t.t, charlie, dave, keySendAmount, assetID, + fn.Some(btcAmt), + ) + } + + logBalance(t.t, nodes, assetID, "after keysend") + + // With the payments sent, we'll now go on chain with a force close + // from Carol. + t.Logf("Force closing channel...") + charlieChanPoint := &lnrpc.ChannelPoint{ + OutputIndex: uint32(assetFundResp.OutputIndex), + FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: assetFundResp.Txid, + }, + } + _, closeTxid, err := net.CloseChannel(charlie, charlieChanPoint, true) + require.NoError(t.t, err) + + t.Logf("Channel closed! Mining blocks, close_txid=%v", closeTxid) + + // Next, we'll mine a block to confirm the force close. + mineBlocks(t, net, 1, 1) + + // At this point, we should have the force close transaction in the set + // of transfers for both nodes. + var forceCloseTransfer *taprpc.ListTransfersResponse + fErr := wait.NoError(func() error { + forceCloseTransfer, err = charlieTap.ListTransfers( + ctxb, &taprpc.ListTransfersRequest{ + AnchorTxid: closeTxid.String(), + }, + ) + if err != nil { + return fmt.Errorf("unable to list charlie transfers: "+ + "%w", err) + } + if len(forceCloseTransfer.Transfers) != 1 { + return fmt.Errorf("charlie is missing force close " + + "transfer") + } + + forceCloseTransfer2, err := daveTap.ListTransfers( + ctxb, &taprpc.ListTransfersRequest{ + AnchorTxid: closeTxid.String(), + }, + ) + if err != nil { + return fmt.Errorf("unable to list dave transfers: %w", + err) + } + if len(forceCloseTransfer2.Transfers) != 1 { + return fmt.Errorf("dave is missing force close " + + "transfer") + } + + return nil + }, defaultTimeout) + require.NoError(t.t, fErr) + + t.Logf("Force close transfer: %v", toProtoJSON(t.t, forceCloseTransfer)) + + // Now that we have the transfer on disk, we'll also assert that the + // universe also has proof for both the relevant transfer outputs. + for _, transfer := range forceCloseTransfer.Transfers { + for _, transferOut := range transfer.Outputs { + assertUniverseProofExists( + t.t, universeTap, assetID, nil, + transferOut.ScriptKey, + transferOut.Anchor.Outpoint, + ) + } + } + + t.Logf("Universe proofs located!") + + time.Sleep(time.Second * 1) + + // We'll mine one more block, which triggers the 1 CSV needed for Dave + // to sweep his output. + mineBlocks(t, net, 1, 0) + + // We should also have a new sweep transaction in the mempool. + daveSweepTxid, err := waitForNTxsInMempool( + net.Miner.Client, 1, time.Second*5, + ) + require.NoError(t.t, err) + + t.Logf("Dave sweep txid: %v", daveSweepTxid) + + // Next, we'll mine a block to confirm Dave's sweep transaction. + // This'll sweep his non-delay commitment output. + mineBlocks(t, net, 1, 1) + + // At this point, a transfer should have been created for Dave's sweep + // transaction. + daveSweepTransfer := locateAssetTransfers( + t.t, daveTap, *daveSweepTxid[0], + ) + + t.Logf("Dave sweep transfer: %v", toProtoJSON(t.t, daveSweepTransfer)) + + time.Sleep(time.Second * 1) + + // Next, we'll mine three additional blocks to trigger the CSV delay + // for Charlie. + mineBlocks(t, net, 3, 0) + + // We expect that Charlie's sweep transaction has been broadcast. + charlieSweepTxid, err := waitForNTxsInMempool( + net.Miner.Client, 1, time.Second*5, + ) + require.NoError(t.t, err) + + t.Logf("Charlie sweep txid: %v", charlieSweepTxid) + + // Now we'll mine a block to confirm Charlie's sweep transaction. + mineBlocks(t, net, 1, 0) + + // Charlie should now have an asset transfer for his sweep transaction. + charlieSweepTransfer := locateAssetTransfers( + t.t, charlieTap, *charlieSweepTxid[0], + ) + + t.Logf("Charlie sweep transfer: %v", toProtoJSON( + t.t, charlieSweepTransfer, + )) + + // Both sides should now reflect their updated asset balances. + daveBalance := uint64(numPayments * keySendAmount) + charlieBalance := itestAsset.Amount - daveBalance + assertAssetBalance(t.t, daveTap, assetID, daveBalance) + assertAssetBalance(t.t, charlieTap, assetID, charlieBalance) + + // Dave should have a single managed UTXO that shows he has a new asset + // UTXO he can use. + assertNumAssetUTXOs(t.t, daveTap, 1) + assertNumAssetUTXOs(t.t, charlieTap, 2) + + // We'll make sure Dave can spend his asset UTXO by sending it all but + // one unit to Zane (the universe). + assetSendAmount := daveBalance - 1 + zaneAddr, err := universeTap.NewAddr(ctxb, &taprpc.NewAddrRequest{ + Amt: assetSendAmount, + AssetId: assetID, + ProofCourierAddr: fmt.Sprintf( + "%s://%s", proof.UniverseRpcCourierType, + charlieTap.node.Cfg.LitAddr(), + ), + }) + require.NoError(t.t, err) + + t.Logf("Sending %v asset from Dave units to Zane...", assetSendAmount) + + // Send the assets to Zane. We expect Dave to have 3 transfers: the + // funding txn, their force close sweep, and now this new send. + itest.AssertAddrCreated(t.t, universeTap, cents, zaneAddr) + sendResp, err := daveTap.SendAsset(ctxb, &taprpc.SendAssetRequest{ + TapAddrs: []string{zaneAddr.Encoded}, + }) + require.NoError(t.t, err) + itest.ConfirmAndAssertOutboundTransfer( + t.t, t.lndHarness.Miner.Client, daveTap, sendResp, assetID, + []uint64{1, assetSendAmount}, 2, 3, + ) + itest.AssertNonInteractiveRecvComplete(t.t, universeTap, 1) + + // And now we also send all assets but one from Charlie to the universe + // to make sure the time lock sweep output can also be spent correctly. + assetSendAmount = charlieBalance - 1 + zaneAddr2, err := universeTap.NewAddr(ctxb, &taprpc.NewAddrRequest{ + Amt: assetSendAmount, + AssetId: assetID, + ProofCourierAddr: fmt.Sprintf( + "%s://%s", proof.UniverseRpcCourierType, + charlieTap.node.Cfg.LitAddr(), + ), + }) + require.NoError(t.t, err) + + t.Logf("Sending %v asset from Charlie units to Zane...", + assetSendAmount) + + itest.AssertAddrCreated(t.t, universeTap, cents, zaneAddr2) + sendResp2, err := charlieTap.SendAsset(ctxb, &taprpc.SendAssetRequest{ + TapAddrs: []string{zaneAddr2.Encoded}, + }) + require.NoError(t.t, err) + itest.ConfirmAndAssertOutboundTransfer( + t.t, t.lndHarness.Miner.Client, charlieTap, sendResp2, assetID, + []uint64{1, assetSendAmount}, 3, 4, + ) + itest.AssertNonInteractiveRecvComplete(t.t, universeTap, 2) +} + +// testCustomChannelsBreach tests a force close scenario that breaches an old +// state, after both parties have an active asset balance. +func testCustomChannelsBreach(_ context.Context, net *NetworkHarness, + t *harnessTest) { + + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplate) + + // Explicitly set the proof courier as Zane (now has no other role + // other than proof shuffling), otherwise a hashmail courier will be + // used. For the funding transaction, we're just posting it and don't + // expect a true receiver. + zane, err := net.NewNode( + t.t, "Zane", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + // For our litd args, make sure that they all seen Zane as the main + // Universe server. + litdArgs = append(litdArgs, fmt.Sprintf( + "--taproot-assets.proofcourieraddr=%s://%s", + proof.UniverseRpcCourierType, zane.Cfg.LitAddr(), + )) + + // Charlie will be the breached party. We set --nolisten to ensure Dave + // won't be able to connect to him and trigger the channel protection + // logic automatically. We also can't have Charlie automatically + // reconnect too early, otherwise DLP would be initiated instead of the + // breach we want to provoke. + charlieFlags := append( + slices.Clone(lndArgs), "--nolisten", "--minbackoff=1h", + ) + + // For this simple test, we'll just have Carol -> Dave as an assets + // channel. + charlie, err := net.NewNode( + t.t, "Charlie", charlieFlags, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + dave, err := net.NewNode(t.t, "Dave", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + + // Next we'll connect all the nodes and also fund them with some coins. + nodes := []*HarnessNode{charlie, dave} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + universeTap := newTapClient(t.t, zane) + charlieTap := newTapClient(t.t, charlie) + daveTap := newTapClient(t.t, dave) + + ctxb := context.Background() + + // Now we'll make an asset for Charlie that we'll use in the test to + // open a channel. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, charlieTap, + []*mintrpc.MintAssetRequest{ + { + Asset: itestAsset, + }, + }, + ) + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + + t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) + syncUniverses(t.t, charlieTap, dave) + t.Logf("Universes synced between all nodes, distributing assets...") + + // Next we can open an asset channel from Charlie -> Dave, then kick + // off the main scenario. + t.Logf("Opening asset channels...") + assetFundResp, err := charlieTap.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: fundingAmount, + AssetId: assetID, + PeerPubkey: dave.PubKey[:], + FeeRateSatPerVbyte: 5, + }, + ) + require.NoError(t.t, err) + t.Logf("Funded channel between Charlie and Dave: %v", assetFundResp) + + // With the channel open, mine a block to confirm it. + mineBlocks(t, net, 6, 1) + + // A transfer for the funding transaction should be found in Charlie's + // DB. + fundingTxid, err := chainhash.NewHashFromStr(assetFundResp.Txid) + require.NoError(t.t, err) + assetFundingTransfer := locateAssetTransfers( + t.t, charlieTap, *fundingTxid, + ) + + t.Logf("Channel funding transfer: %v", + toProtoJSON(t.t, assetFundingTransfer)) + + // Charlie's balance should reflect that the funding asset is now + // excluded from balance reporting by tapd. + assertAssetBalance( + t.t, charlieTap, assetID, itestAsset.Amount-fundingAmount, + ) + + // Make sure that Charlie properly uploaded funding proof to the + // Universe server. + fundingScriptTree := tapscript.NewChannelFundingScriptTree() + fundingScriptKey := fundingScriptTree.TaprootKey + fundingScriptTreeBytes := fundingScriptKey.SerializeCompressed() + assertUniverseProofExists( + t.t, universeTap, assetID, nil, fundingScriptTreeBytes, + fmt.Sprintf( + "%v:%v", assetFundResp.Txid, assetFundResp.OutputIndex, + ), + ) + + // Make sure the channel shows the correct asset information. + assertAssetChan(t.t, charlie, dave, fundingAmount, cents) + + // 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(charlie, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, charlie)) + + // Next, we'll make keysend payments from Charlie to Dave. we'll use + // this to reach a state where both parties have funds in the channel. + const ( + numPayments = 5 + keySendAmount = 100 + btcAmt = int64(5_000) + ) + for i := 0; i < numPayments; i++ { + sendAssetKeySendPayment( + t.t, charlie, dave, keySendAmount, assetID, + fn.Some(btcAmt), + ) + } + + logBalance(t.t, nodes, assetID, "after keysend -- breach state") + + // Now we'll create an on disk snapshot that we'll use to restore back + // to as our breached state. + require.NoError(t.t, net.StopAndBackupDB(dave)) + connectAllNodes(t.t, net, nodes) + + // We'll send one more keysend payment now to revoke the state we were + // just at above. + sendAssetKeySendPayment( + t.t, charlie, dave, keySendAmount, assetID, fn.Some(btcAmt), + ) + logBalance(t.t, nodes, assetID, "after keysend -- final state") + + // With the final state achieved, we'll now restore Dave (who will be + // force closing) to that old state, the breach state. + require.NoError(t.t, net.StopAndRestoreDB(dave)) + + // With Dave restored, we'll now execute the force close. + t.Logf("Force close by Dave to breach...") + daveChanPoint := &lnrpc.ChannelPoint{ + OutputIndex: uint32(assetFundResp.OutputIndex), + FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: assetFundResp.Txid, + }, + } + _, breachTxid, err := net.CloseChannel(dave, daveChanPoint, true) + require.NoError(t.t, err) + + t.Logf("Channel closed! Mining blocks, close_txid=%v", breachTxid) + + // Next, we'll mine a block to confirm the breach transaction. + mineBlocks(t, net, 1, 1) + + // We should be able to find the transfer of the breach for both + // parties. + charlieBreachTransfer := locateAssetTransfers( + t.t, charlieTap, *breachTxid, + ) + daveBreachTransfer := locateAssetTransfers( + t.t, daveTap, *breachTxid, + ) + + t.Logf("Charlie breach transfer: %v", + toProtoJSON(t.t, charlieBreachTransfer)) + t.Logf("Dave breach transfer: %v", + toProtoJSON(t.t, daveBreachTransfer)) + + // With the breach transaction mined, Charlie should now have a + // transaction in the mempool sweeping the *both* commitment outputs. + charlieJusticeTxid, err := waitForNTxsInMempool( + net.Miner.Client, 1, time.Second*5, + ) + require.NoError(t.t, err) + + t.Logf("Charlie justice txid: %v", charlieJusticeTxid) + + // Next, we'll mine a block to confirm Charlie's justice transaction. + mineBlocks(t, net, 1, 1) + + // Charlie should now have a transfer for his justice transaction. + charlieJusticeTransfer := locateAssetTransfers( + t.t, charlieTap, *charlieJusticeTxid[0], + ) + + t.Logf("Charlie justice transfer: %v", + toProtoJSON(t.t, charlieJusticeTransfer)) + + // Charlie's balance should now be the same as before the breach + // attempt: the amount he minted at the very start. + charlieBalance := itestAsset.Amount + assertAssetBalance(t.t, charlieTap, assetID, charlieBalance) + + t.Logf("Charlie balance after breach: %d", charlieBalance) + + // Charlie should now have 2 total UTXOs: the change from the funding + // output, and now the sweep output from the justice transaction. + charlieUTXOs := assertNumAssetUTXOs(t.t, charlieTap, 2) + + t.Logf("Charlie UTXOs after breach: %v", toProtoJSON(t.t, charlieUTXOs)) +} + +// testCustomChannelsLiquidityEdgeCases is a test that runs through some +// taproot asset channel liquidity related edge cases. +func testCustomChannelsLiquidityEdgeCases(ctxb context.Context, + net *NetworkHarness, t *harnessTest) { + + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplate) + + // Explicitly set the proof courier as Zane (now has no other role + // other than proof shuffling), otherwise a hashmail courier will be + // used. For the funding transaction, we're just posting it and don't + // expect a true receiver. + 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(), + )) + + // The topology we are going for looks like the following: + // + // Charlie --[assets]--> Dave --[sats]--> Erin --[assets]--> Fabia + // | + // | + // [assets] + // | + // v + // Yara + // + // With [assets] being a custom channel and [sats] being a normal, BTC + // only channel. + // All 5 nodes need to be full litd nodes running in integrated mode + // with tapd included. We also need specific flags to be enabled, so we + // create 5 completely new nodes, ignoring the two default nodes that + // are created by the harness. + charlie, err := net.NewNode( + t.t, "Charlie", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + dave, err := net.NewNode(t.t, "Dave", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + erin, err := net.NewNode(t.t, "Erin", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + fabia, err := net.NewNode( + t.t, "Fabia", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + yara, err := net.NewNode( + t.t, "Yara", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + nodes := []*HarnessNode{charlie, dave, erin, fabia, yara} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + // Create the normal channel between Dave and Erin. + t.Logf("Opening normal channel between Dave and Erin...") + channelOp := openChannelAndAssert( + t, net, dave, erin, lntest.OpenChannelParams{ + Amt: 10_000_000, + SatPerVByte: 5, + }, + ) + defer closeChannelAndAssert(t, net, dave, channelOp, true) + + // This is the only public channel, we need everyone to be aware of it. + assertChannelKnown(t.t, charlie, channelOp) + assertChannelKnown(t.t, fabia, channelOp) + + universeTap := newTapClient(t.t, zane) + charlieTap := newTapClient(t.t, charlie) + daveTap := newTapClient(t.t, dave) + erinTap := newTapClient(t.t, erin) + fabiaTap := newTapClient(t.t, fabia) + yaraTap := newTapClient(t.t, yara) + + // Mint an asset on Charlie and sync all nodes to Charlie as the + // universe. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, charlieTap, + []*mintrpc.MintAssetRequest{ + { + Asset: itestAsset, + }, + }, + ) + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + + t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) + syncUniverses(t.t, charlieTap, dave, erin, fabia, yara) + t.Logf("Universes synced between all nodes, distributing assets...") + + const ( + daveFundingAmount = uint64(400_000) + erinFundingAmount = uint64(200_000) + ) + charlieFundingAmount := cents.Amount - uint64(2*400_000) + + _, _, _ = createTestAssetNetwork( + t, net, charlieTap, daveTap, erinTap, fabiaTap, yaraTap, + universeTap, cents, 400_000, charlieFundingAmount, + daveFundingAmount, erinFundingAmount, 0, + ) + + // 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(charlie, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, charlie)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, yara)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(yara, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(erin, fabia)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(fabia, erin)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(charlie, erin)) + + logBalance(t.t, nodes, assetID, "initial") + + // Normal case. + // Send 50 assets from Charlie to Dave. + sendAssetKeySendPayment( + t.t, charlie, dave, 50, assetID, fn.None[int64](), + ) + + logBalance(t.t, nodes, assetID, "after 50 assets") + + // Normal case. + // Send 1k sats from Charlie to Dave. + sendKeySendPayment(t.t, charlie, dave, 1000) + + logBalance(t.t, nodes, assetID, "after 1k sats") + + // Edge case: The channel reserve check should trigger, and we should + // get a payment failure, not a timeout. + // + // Now Dave tries to send 50 assets to Charlie. There shouldn't be + // enough sats in the channel. + // + // Assume an acceptable completion window which is half the payment + // timeout. If the payment succeeds within this duration this means we + // didn't fall into a routing loop. + timeoutChan := time.After(PaymentTimeout / 2) + done := make(chan bool, 1) + + go func() { + sendAssetKeySendPayment( + t.t, dave, charlie, 50, assetID, fn.None[int64](), + withFailure(lnrpc.Payment_FAILED, failureNoRoute), + ) + + done <- true + }() + + select { + case <-done: + case <-timeoutChan: + t.Fatalf("Payment didn't fail within expected time duration") + } + + logBalance(t.t, nodes, assetID, "after failed 50 assets") + + // Send 10k sats from Charlie to Dave. + sendKeySendPayment(t.t, charlie, dave, 10000) + + logBalance(t.t, nodes, assetID, "10k sats") + + // Now Dave tries to send 50 assets again, this time he should have + // enough sats. + sendAssetKeySendPayment( + t.t, dave, charlie, 50, assetID, fn.None[int64](), + ) + + logBalance(t.t, nodes, assetID, "after 50 sats backwards") + + // Edge case: This refers to a bug where an asset allocation would be + // expected for this HTLC. This is a dust HTLC and it can not carry + // assets. + // + // Send 1 sat from Charlie to Dave. + sendKeySendPayment(t.t, charlie, dave, 1) + + logBalance(t.t, nodes, assetID, "after 1 sat") + + // Pay a normal bolt11 invoice involving RFQ flow. + _ = createAndPayNormalInvoice( + t.t, charlie, dave, erin, 20_000, assetID, withSmallShards(), + ) + + logBalance(t.t, nodes, assetID, "after 20k sat asset payment") + + // Edge case: There was a bug when paying an asset invoice that would + // evaluate to more than the channel capacity, causing a payment failure + // even though enough asset balance exists. + // + // Pay a bolt11 invoice with assets, which evaluates to more than the + // channel btc capacity. + _ = createAndPayNormalInvoice( + t.t, charlie, dave, erin, 1_000_000, assetID, withSmallShards(), + ) + + logBalance(t.t, nodes, assetID, "after big asset payment (btc "+ + "invoice, multi-hop)") + + // Edge case: Big asset invoice paid by direct peer with assets. + const bigAssetAmount = 100_000 + invoiceResp := createAssetInvoice( + t.t, charlie, dave, bigAssetAmount, assetID, + ) + + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, + ) + + logBalance(t.t, nodes, assetID, "after big asset payment (asset "+ + "invoice, direct)") + + // Make sure the invoice on the receiver side and the payment on the + // sender side show the individual HTLCs that arrived for it and that + // they show the correct asset amounts when decoded. + assertInvoiceHtlcAssets( + t.t, dave, invoiceResp, assetID, bigAssetAmount, + ) + assertPaymentHtlcAssets( + t.t, charlie, invoiceResp.RHash, assetID, bigAssetAmount, + ) + + // Dave sends 200k assets and 5k sats to Yara. + sendAssetKeySendPayment( + t.t, dave, yara, 2*bigAssetAmount, assetID, fn.None[int64](), + ) + sendKeySendPayment(t.t, dave, yara, 5_000) + + logBalance(t.t, nodes, assetID, "after 200k assets to Yara") + + // Edge case: Now Charlie creates a big asset invoice to be paid for by + // Yara with assets. This is a multi-hop payment going over 2 asset + // channels, where the total asset value exceeds the btc capacity of the + // channels. + invoiceResp = createAssetInvoice( + t.t, dave, charlie, bigAssetAmount, assetID, + ) + + payInvoiceWithAssets( + t.t, yara, dave, invoiceResp.PaymentRequest, assetID, + ) + + logBalance(t.t, nodes, assetID, "after big asset payment (asset "+ + "invoice, multi-hop)") + + // Edge case: Now Charlie creates a tiny asset invoice to be paid for by + // Yara with satoshi. This is a multi-hop payment going over 2 asset + // channels, where the total asset value is less than the default anchor + // amount of 354 sats. + createAssetInvoice(t.t, dave, charlie, 1, assetID, withInvoiceErrSubStr( + "cannot create invoice over 1 asset units, as the minimal "+ + "transportable amount", + )) + + logBalance(t.t, nodes, assetID, "after small payment (asset "+ + "invoice, <354sats)") + + // Edge case: We now create a small BTC invoice on Erin and ask Charlie + // to pay it with assets. We should get a payment failure as the amount + // is too small to be paid with assets economically. But a payment is + // still possible, since the amount is large enough to represent a + // single unit (17.1 sat per unit). + btcInvoiceResp, err := erin.AddInvoice(ctxb, &lnrpc.Invoice{ + Memo: "small BTC invoice", + ValueMsat: 18_000, + }) + require.NoError(t.t, err) + payInvoiceWithAssets( + t.t, charlie, dave, btcInvoiceResp.PaymentRequest, assetID, + withFeeLimit(2_000), withPayErrSubStr( + "rejecting payment of 20000 mSAT", + ), + ) + + // When we override the uneconomical payment, it should succeed. + payInvoiceWithAssets( + t.t, charlie, dave, btcInvoiceResp.PaymentRequest, assetID, + withFeeLimit(2_000), withAllowOverpay(), + ) + logBalance( + t.t, nodes, assetID, "after small payment (BTC invoice 1 sat)", + ) + + // When we try to pay an invoice amount that's smaller than the + // corresponding value of a single asset unit, the payment will always + // be rejected, even if we set the allow_uneconomical flag. + btcInvoiceResp, err = erin.AddInvoice(ctxb, &lnrpc.Invoice{ + Memo: "very small BTC invoice", + ValueMsat: 1_000, + }) + require.NoError(t.t, err) + payInvoiceWithAssets( + t.t, charlie, dave, btcInvoiceResp.PaymentRequest, assetID, + withFeeLimit(1_000), withAllowOverpay(), withPayErrSubStr( + "rejecting payment of 2000 mSAT", + ), + ) + + // Edge case: Now Dave creates an asset invoice to be paid for by + // Yara with satoshi. For the last hop we try to settle the invoice in + // satoshi, where we will check whether Dave's strict forwarding works + // as expected. Charlie is only used as a dummy RFQ peer in this case, + // Yara totally ignored the RFQ hint and pays agnostically with sats. + invoiceResp = createAssetInvoice(t.t, charlie, dave, 22, assetID) + + stream, err := dave.InvoicesClient.SubscribeSingleInvoice( + ctxb, &invoicesrpc.SubscribeSingleInvoiceRequest{ + RHash: invoiceResp.RHash, + }, + ) + require.NoError(t.t, err) + + // Yara pays Dave with enough satoshis, but Charlie will not settle as + // he expects assets. + payInvoiceWithSatoshiLastHop( + t.t, yara, invoiceResp, dave.PubKey[:], lnrpc.Payment_FAILED, + ) + + t.lndHarness.LNDHarness.AssertInvoiceState(stream, lnrpc.Invoice_OPEN) + + logBalance(t.t, nodes, assetID, "after failed payment (asset "+ + "invoice, strict forwarding)") + + // Edge case: Check if the RFQ HTLC tracking accounts for cancelled + // HTLCs. We achieve this by manually creating & using an RFQ quote with + // a set max amount. We first pay to a hodl invoice that we eventually + // cancel, then pay to a normal invoice which should succeed. + + // We start by sloshing some funds in the Erin<->Fabia. + sendAssetKeySendPayment( + t.t, erin, fabia, 100_000, assetID, fn.Some[int64](20_000), + ) + + logBalance(t.t, nodes, assetID, "balance after 1st slosh") + + // We create the RFQ order. We set the max amt to ~180k sats which is + // going to evaluate to about 10k assets. + inOneHour := time.Now().Add(time.Hour) + resQ, err := charlieTap.RfqClient.AddAssetSellOrder( + ctxb, &rfqrpc.AddAssetSellOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: assetID, + }, + }, + PaymentMaxAmt: 180_000_000, + Expiry: uint64(inOneHour.Unix()), + PeerPubKey: dave.PubKey[:], + TimeoutSeconds: 100, + }, + ) + require.NoError(t.t, err) + + // We now create a hodl invoice on Fabia, for 10k assets. + hodlInv := createAssetHodlInvoice(t.t, erin, fabia, 10_000, assetID) + + // Charlie tries to pay via Dave, by providing the RFQ quote ID that was + // manually created above. + var quoteID rfqmsg.ID + copy(quoteID[:], resQ.GetAcceptedQuote().Id) + payInvoiceWithAssets( + t.t, charlie, dave, hodlInv.payReq, assetID, withSmallShards(), + withFailure(lnrpc.Payment_IN_FLIGHT, failureNone), + withRFQ(quoteID), + ) + + // We now assert that the expected numbers of HTLCs are present on each + // node. + // Reminder, topology looks like this: + // + // Charlie <-> Dave <-> Erin <-> Fabia + // + // Therefore the routing nodes should have double the number of HTLCs + // required for the payment present. + assertNumHtlcs(t.t, charlie, 3) + assertNumHtlcs(t.t, dave, 6) + assertNumHtlcs(t.t, erin, 6) + assertNumHtlcs(t.t, fabia, 3) + + // Now let's cancel the invoice on Fabia. + payHash := hodlInv.preimage.Hash() + _, err = fabia.InvoicesClient.CancelInvoice( + ctxb, &invoicesrpc.CancelInvoiceMsg{ + PaymentHash: payHash[:], + }, + ) + require.NoError(t.t, err) + + // There should be no HTLCs present on any channel. + assertNumHtlcs(t.t, charlie, 0) + assertNumHtlcs(t.t, dave, 0) + assertNumHtlcs(t.t, erin, 0) + assertNumHtlcs(t.t, fabia, 0) + + // Now Fabia creates the normal invoice. + invoiceResp = createAssetInvoice( + t.t, erin, fabia, 10_000, assetID, + ) + + // Now Charlie pays the invoice, again by using the manually specified + // RFQ quote ID. This payment should succeed. + payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, + withSmallShards(), withRFQ(quoteID), + ) + + logBalance(t.t, nodes, assetID, "after manual rfq hodl") + + // Edge case: Charlie negotiates a quote with Dave which has a low max + // amount (~170k sats). Then Charlie creates an invoice with a total + // amount slightly larger than the max allowed in the quote (200k sats). + // Erin will try to pay that invoice with sats, in shards of max size + // 80k sats. Dave will eventually stop forwarding HTLCs as the RFQ HTLC + // tracking mechanism should stop them from being forwarded, as they + // violate the maximum allowed amount of the quote. + + // Charlie starts by negotiating the quote. + inOneHour = time.Now().Add(time.Hour) + res, err := charlieTap.RfqClient.AddAssetBuyOrder( + ctxb, &rfqrpc.AddAssetBuyOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: assetID, + }, + }, + AssetMaxAmt: 10_000, + Expiry: uint64(inOneHour.Unix()), + PeerPubKey: dave.PubKey[:], + TimeoutSeconds: 10, + }, + ) + require.NoError(t.t, err) + + type acceptedQuote = *rfqrpc.AddAssetBuyOrderResponse_AcceptedQuote + quote, ok := res.Response.(acceptedQuote) + require.True(t.t, ok) + + // We now manually add the invoice in order to inject the above, + // manually generated, quote. + iResp, err := charlie.AddInvoice(ctxb, &lnrpc.Invoice{ + Memo: "", + Value: 200_000, + RPreimage: bytes.Repeat([]byte{11}, 32), + CltvExpiry: 60, + RouteHints: []*lnrpc.RouteHint{{ + HopHints: []*lnrpc.HopHint{{ + NodeId: dave.PubKeyStr, + ChanId: quote.AcceptedQuote.Scid, + }}, + }}, + }) + require.NoError(t.t, err) + + // Now Erin tries to pay the invoice. Since rfq quote cannot satisfy the + // total amount of the invoice this payment will fail. + payInvoiceWithSatoshi( + t.t, erin, iResp, withPayErrSubStr("context deadline exceeded"), + withFailure(lnrpc.Payment_FAILED, failureNone), + ) + + logBalance(t.t, nodes, assetID, "after small manual rfq") +} + +// testCustomChannelsBalanceConsistency is a test that test the balance of nodes +// under channel opening circumstances. +func testCustomChannelsBalanceConsistency(_ context.Context, + net *NetworkHarness, t *harnessTest) { + + ctxb := context.Background() + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplate) + + // Explicitly set the proof courier as Zane (now has no other role + // other than proof shuffling), otherwise a hashmail courier will be + // used. For the funding transaction, we're just posting it and don't + // expect a true receiver. + 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(), + )) + + charlie, err := net.NewNode( + t.t, "Charlie", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + dave, err := net.NewNode(t.t, "Dave", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + + nodes := []*HarnessNode{charlie, dave} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + charlieTap := newTapClient(t.t, charlie) + daveTap := newTapClient(t.t, dave) + universeTap := newTapClient(t.t, zane) + + // Mint an asset on Charlie and sync Dave to Charlie as the universe. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, charlieTap, + []*mintrpc.MintAssetRequest{ + { + Asset: itestAsset, + }, + }, + ) + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + var groupKey []byte + if cents.AssetGroup != nil { + groupKey = cents.AssetGroup.TweakedGroupKey + } + + t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) + syncUniverses(t.t, charlieTap, dave) + t.Logf("Universes synced between all nodes, distributing assets...") + + charlieBalance := cents.Amount + + // Charlie should have a single balance output with the full balance. + assertAssetBalance(t.t, charlieTap, assetID, cents.Amount) + + // The script key should be local to charlie, and the script key should + // be known. It is after all the asset he just minted himself. + scriptKeyLocal := true + scriptKeyKnown := false + scriptKeyHasScriptPath := false + + scriptKey, err := schnorr.ParsePubKey(cents.ScriptKey[1:]) + require.NoError(t.t, err) + assertAssetExists( + t.t, charlieTap, assetID, charlieBalance, + scriptKey, scriptKeyLocal, scriptKeyKnown, + scriptKeyHasScriptPath, + ) + + fundingScriptTree := tapscript.NewChannelFundingScriptTree() + fundingScriptKey := fundingScriptTree.TaprootKey + fundingScriptTreeBytes := fundingScriptKey.SerializeCompressed() + + fundRespCD, err := charlieTap.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: charlieBalance, + AssetId: assetID, + PeerPubkey: daveTap.node.PubKey[:], + FeeRateSatPerVbyte: 5, + PushSat: 0, + }, + ) + require.NoError(t.t, err) + t.Logf("Funded channel between Charlie and Dave: %v", fundRespCD) + + // Make sure the pending channel shows up in the list and has the + // custom records set as JSON. + assertPendingChannels( + t.t, charlieTap.node, cents, 1, charlieBalance, 0, + ) + + // Let's confirm the channel. + mineBlocks(t, net, 6, 1) + + // Tapd should not report any balance for Charlie, since the asset is + // used in a funding transaction. It should also not report any balance + // for Dave. All those balances are reported through channel balances. + assertAssetBalance(t.t, charlieTap, assetID, 0) + assertAssetBalance(t.t, daveTap, assetID, 0) + + // There should only be a single asset piece for Charlie, the one in the + // channel. + assertNumAssetOutputs(t.t, charlieTap, assetID, 1) + + // The script key should now not be local anymore, since he funded a + // channel with it. Charlie does still know the script key though. + scriptKeyLocal = false + scriptKeyKnown = true + scriptKeyHasScriptPath = true + assertAssetExists( + t.t, charlieTap, assetID, charlieBalance, + fundingScriptKey, scriptKeyLocal, scriptKeyKnown, + scriptKeyHasScriptPath, + ) + + // Assert that the proofs for both channels has been uploaded to the + // designated Universe server. + assertUniverseProofExists( + t.t, universeTap, assetID, groupKey, fundingScriptTreeBytes, + fmt.Sprintf("%v:%v", fundRespCD.Txid, fundRespCD.OutputIndex), + ) + + // Make sure the channel shows the correct asset information. + assertAssetChan( + t.t, charlieTap.node, daveTap.node, charlieBalance, cents, + ) + + logBalance(t.t, nodes, assetID, "initial") + + // Normal case. + // Send 500 assets from Charlie to Dave. + sendAssetKeySendPayment( + t.t, charlie, dave, 500, assetID, fn.None[int64](), + ) + + logBalance(t.t, nodes, assetID, "after 500 assets") + + // Tapd should still not report balances for Charlie and Dave, since + // they are still locked up in the funding transaction. + assertAssetBalance(t.t, charlieTap, assetID, 0) + assertAssetBalance(t.t, daveTap, assetID, 0) + + // Send 10k sats from Charlie to Dave. Dave needs the sats to be able to + // send assets. + sendKeySendPayment(t.t, charlie, dave, 10000) + + // Now Dave tries to send 250 assets. + sendAssetKeySendPayment( + t.t, dave, charlie, 250, assetID, fn.None[int64](), + ) + + logBalance(t.t, nodes, assetID, "after 250 sats backwards") + + // Tapd should still not report balances for Charlie and Dave, since + // they are still locked up in the funding transaction. + assertAssetBalance(t.t, charlieTap, assetID, 0) + assertAssetBalance(t.t, daveTap, assetID, 0) + + // We will now close the channel. + t.Logf("Close the channel between Charlie and Dave...") + charlieChanPoint := &lnrpc.ChannelPoint{ + OutputIndex: uint32(fundRespCD.OutputIndex), + FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: fundRespCD.Txid, + }, + } + + closeChannelAndAssert(t, net, charlie, charlieChanPoint, false) + + // Charlie should have a single balance output with the balance 250 less + // than the total amount minted. + assertAssetBalance(t.t, charlieTap, assetID, charlieBalance-250) + assertAssetBalance(t.t, daveTap, assetID, 250) + + // The script key should now be local to both Charlie and Dave, since + // the channel was closed. + scriptKeyLocal = true + scriptKeyKnown = true + scriptKeyHasScriptPath = false + assertAssetExists( + t.t, charlieTap, assetID, charlieBalance-250, + nil, scriptKeyLocal, scriptKeyKnown, scriptKeyHasScriptPath, + ) + assertAssetExists( + t.t, daveTap, assetID, 250, + nil, scriptKeyLocal, scriptKeyKnown, scriptKeyHasScriptPath, + ) + + assertNumAssetOutputs(t.t, charlieTap, assetID, 1) + assertNumAssetOutputs(t.t, daveTap, assetID, 1) +} + +// testCustomChannelsSingleAssetMultiInput tests whether it is possible to fund +// a channel using FundChannel that uses multiple inputs from the same asset. +func testCustomChannelsSingleAssetMultiInput(_ context.Context, + net *NetworkHarness, t *harnessTest) { + + ctxb := context.Background() + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplate) + + 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(), + )) + + charlie, err := net.NewNode( + t.t, "Charlie", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + dave, err := net.NewNode(t.t, "Dave", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + + nodes := []*HarnessNode{charlie, dave} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + charlieTap := newTapClient(t.t, charlie) + daveTap := newTapClient(t.t, dave) + + // Mint an assets on Charlie and sync Dave to Charlie as the universe. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, charlieTap, + []*mintrpc.MintAssetRequest{ + { + Asset: itestAsset, + }, + }, + ) + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + + t.Logf("Minted %d lightning cents, syncing universes...", + cents.Amount) + syncUniverses(t.t, charlieTap, dave) + t.Logf("Universes synced between all nodes, distributing assets...") + + // Charlie should have two balance outputs with the full balance. + assertAssetBalance(t.t, charlieTap, assetID, cents.Amount) + + // Send assets to Dave so he can fund a channel. + halfCentsAmount := cents.Amount / 2 + daveAddr1, err := daveTap.NewAddr(ctxb, &taprpc.NewAddrRequest{ + Amt: halfCentsAmount, + AssetId: assetID, + ProofCourierAddr: fmt.Sprintf( + "%s://%s", proof.UniverseRpcCourierType, + charlieTap.node.Cfg.LitAddr(), + ), + }) + require.NoError(t.t, err) + daveAddr2, err := daveTap.NewAddr(ctxb, &taprpc.NewAddrRequest{ + Amt: halfCentsAmount, + AssetId: assetID, + ProofCourierAddr: fmt.Sprintf( + "%s://%s", proof.UniverseRpcCourierType, + charlieTap.node.Cfg.LitAddr(), + ), + }) + require.NoError(t.t, err) + + t.Logf("Sending %v asset units to Dave twice...", halfCentsAmount) + + // Send the assets to Dave. + itest.AssertAddrCreated(t.t, daveTap, cents, daveAddr1) + itest.AssertAddrCreated(t.t, daveTap, cents, daveAddr2) + sendResp, err := charlieTap.SendAsset(ctxb, &taprpc.SendAssetRequest{ + TapAddrs: []string{daveAddr1.Encoded, daveAddr2.Encoded}, + }) + require.NoError(t.t, err) + itest.ConfirmAndAssertOutboundTransferWithOutputs( + t.t, t.lndHarness.Miner.Client, charlieTap, sendResp, assetID, + []uint64{ + cents.Amount - 2*halfCentsAmount, halfCentsAmount, + halfCentsAmount, + }, 0, 1, 3, + ) + itest.AssertNonInteractiveRecvComplete(t.t, daveTap, 2) + + // Fund a channel using multiple inputs from the same asset. + fundRespCD, err := daveTap.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: 2 * halfCentsAmount, + AssetId: assetID, + PeerPubkey: charlieTap.node.PubKey[:], + FeeRateSatPerVbyte: 5, + PushSat: 0, + }, + ) + require.NoError(t.t, err) + t.Logf("Funded channel between Charlie and Dave: %v", fundRespCD) + + // Let's confirm the channel. + mineBlocks(t, net, 6, 1) + + // Tapd should not report any balance for Charlie, since the asset is + // used in a funding transaction. It should also not report any balance + // for Dave. All those balances are reported through channel balances. + assertAssetBalance(t.t, charlieTap, assetID, 0) + assertAssetBalance(t.t, daveTap, assetID, 0) + + // Make sure the channel shows the correct asset information. + assertAssetChan( + t.t, charlieTap.node, daveTap.node, 2*halfCentsAmount, cents, + ) +} + +// testCustomChannelsOraclePricing tests that all asset transfers are correctly +// priced when using an oracle that isn't tapd's mock oracle. +func testCustomChannelsOraclePricing(_ context.Context, + net *NetworkHarness, t *harnessTest) { + + usdMetaData := &taprpc.AssetMeta{ + Data: []byte(`{ +"description":"this is a USD stablecoin with decimal display of 6" +}`), + Type: taprpc.AssetMetaType_META_TYPE_JSON, + } + + const decimalDisplay = 6 + itestAsset = &mintrpc.MintAsset{ + AssetType: taprpc.AssetType_NORMAL, + Name: "USD", + AssetMeta: usdMetaData, + // We mint 1 million USD with a decimal display of 6, which + // results in 1 trillion asset units. + Amount: 1_000_000_000_000, + DecimalDisplay: decimalDisplay, + } + + oracleAddr := fmt.Sprintf("localhost:%d", port.NextAvailablePort()) + oracle := newOracleHarness(oracleAddr) + oracle.start(t.t) + t.t.Cleanup(oracle.stop) + + ctxb := context.Background() + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplateNoOracle) + litdArgs = append(litdArgs, fmt.Sprintf( + "--taproot-assets.experimental.rfq.priceoracleaddress="+ + "rfqrpc://%s", oracleAddr, + )) + + // Explicitly set the proof courier as Zane (now has no other role + // other than proof shuffling), otherwise a hashmail courier will be + // used. For the funding transaction, we're just posting it and don't + // expect a true receiver. + 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(), + )) + + // The topology we are going for looks like the following: + // + // Charlie --[assets]--> Dave --[sats]--> Erin --[assets]--> Fabia + // | + // | + // [assets] + // | + // v + // Yara + // + // With [assets] being a custom channel and [sats] being a normal, BTC + // only channel. + // All 5 nodes need to be full litd nodes running in integrated mode + // with tapd included. We also need specific flags to be enabled, so we + // create 5 completely new nodes, ignoring the two default nodes that + // are created by the harness. + charlie, err := net.NewNode( + t.t, "Charlie", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + dave, err := net.NewNode(t.t, "Dave", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + erin, err := net.NewNode(t.t, "Erin", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + fabia, err := net.NewNode( + t.t, "Fabia", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + yara, err := net.NewNode( + t.t, "Yara", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + nodes := []*HarnessNode{charlie, dave, erin, fabia, yara} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + // Create the normal channel between Dave and Erin. + t.Logf("Opening normal channel between Dave and Erin...") + const btcChannelFundingAmount = 10_000_000 + chanPointDE := openChannelAndAssert( + t, net, dave, erin, lntest.OpenChannelParams{ + Amt: btcChannelFundingAmount, + SatPerVByte: 5, + }, + ) + defer closeChannelAndAssert(t, net, dave, chanPointDE, false) + + // This is the only public channel, we need everyone to be aware of it. + assertChannelKnown(t.t, charlie, chanPointDE) + assertChannelKnown(t.t, fabia, chanPointDE) + + universeTap := newTapClient(t.t, zane) + charlieTap := newTapClient(t.t, charlie) + daveTap := newTapClient(t.t, dave) + erinTap := newTapClient(t.t, erin) + fabiaTap := newTapClient(t.t, fabia) + yaraTap := newTapClient(t.t, yara) + + // Mint an asset on Charlie and sync Dave to Charlie as the universe. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, charlieTap, + []*mintrpc.MintAssetRequest{ + { + Asset: itestAsset, + }, + }, + ) + usdAsset := mintedAssets[0] + assetID := usdAsset.AssetGenesis.AssetId + + // Now that we've minted the asset, we can set the price in the oracle. + var id asset.ID + copy(id[:], assetID) + + // Let's assume the current USD price for 1 BTC is 66,548.40. We'll take + // that price and add a 4% spread, 2% on each side (buy/sell) to earn + // money as the oracle. 2% is 1,330.97, so we'll set the sell price to + // 65,217.43 and the purchase price to 67,879.37. + // The following numbers are to help understand the magic numbers below. + // They're the price in USD/BTC, the price of 1 USD in sats and the + // expected price in asset units per BTC. + // 65,217.43 => 1533.332 => 65_217_430_000 + // 66,548.40 => 1502.666 => 66_548_400_000 + // 67,879.37 => 1473.202 => 67_879_370_000 + salePrice := rfqmath.NewBigIntFixedPoint(65_217_43, 2) + purchasePrice := rfqmath.NewBigIntFixedPoint(67_879_37, 2) + + // We now have the prices defined in USD. But the asset has a decimal + // display of 6, so we need to multiply them by 10^6. + factor := rfqmath.NewBigInt( + big.NewInt(int64(math.Pow10(decimalDisplay))), + ) + salePrice.Coefficient = salePrice.Coefficient.Mul(factor) + purchasePrice.Coefficient = purchasePrice.Coefficient.Mul(factor) + oracle.setPrice(id, purchasePrice, salePrice) + + t.Logf("Minted %d USD assets, syncing universes...", usdAsset.Amount) + syncUniverses(t.t, charlieTap, dave, erin, fabia, yara) + t.Logf("Universes synced between all nodes, distributing assets...") + + const ( + sendAmount = uint64(400_000_000) + daveFundingAmount = uint64(400_000_000) + erinFundingAmount = uint64(200_000_000) + ) + charlieFundingAmount := usdAsset.Amount - 2*sendAmount + + chanPointCD, chanPointDY, chanPointEF := createTestAssetNetwork( + t, net, charlieTap, daveTap, erinTap, fabiaTap, yaraTap, + universeTap, usdAsset, sendAmount, charlieFundingAmount, + daveFundingAmount, erinFundingAmount, 0, + ) + + // 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(charlie, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, charlie)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, yara)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(yara, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(erin, fabia)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(fabia, erin)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(charlie, erin)) + + // We now create an invoice at Fabia for 100 USD, which is 100_000_000 + // asset units with decimal display of 6. + const fabiaInvoiceAssetAmount = 100_000_000 + invoiceResp := createAssetInvoice( + t.t, erin, fabia, fabiaInvoiceAssetAmount, assetID, + ) + decodedInvoice, err := fabia.DecodePayReq(ctxb, &lnrpc.PayReqString{ + PayReq: invoiceResp.PaymentRequest, + }) + require.NoError(t.t, err) + + // The invoice amount should come out as 100 * 1533.332. + require.EqualValues(t.t, 153_333_242, decodedInvoice.NumMsat) + + numUnits, rate := payInvoiceWithAssets( + t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, + ) + logBalance(t.t, nodes, assetID, "after invoice") + + // The calculated amount Charlie has to pay should come out as + // 153_333_242 / 1473.202, which is quite exactly 4% more than will + // arrive at the destination (which is the oracle's configured spread). + // This is before routing fees though. + const charlieInvoiceAmount = 104_081_638 + require.EqualValues(t.t, charlieInvoiceAmount, numUnits) + + // The default routing fees are 1ppm + 1msat per hop, and we have 2 + // hops in total. + charliePaidMSat := addRoutingFee(addRoutingFee(lnwire.MilliSatoshi( + decodedInvoice.NumMsat, + ))) + charliePaidAmount := rfqmath.MilliSatoshiToUnits( + charliePaidMSat, rate, + ).ScaleTo(0).ToUint64() + assertPaymentHtlcAssets( + t.t, charlie, invoiceResp.RHash, assetID, charliePaidAmount, + ) + + // We now make sure the asset and satoshi channel balances are exactly + // what we expect them to be. + var ( + // channelFundingAmount is the hard coded satoshi amount that + // currently goes into asset channels. + channelFundingAmount int64 = 100_000 + + // commitFeeP2TR is the default commit fee for a P2TR channel + // commitment with 4 outputs (to_local, to_remote, 2 anchors). + commitFeeP2TR int64 = 2420 + commitFeeP2WSH int64 = 2810 + anchorAmount int64 = 330 + assetHtlcCarryAmount = int64( + rfqmath.DefaultOnChainHtlcSat, + ) + unbalancedLocalAmount = channelFundingAmount - commitFeeP2TR - + anchorAmount + balancedLocalAmount = unbalancedLocalAmount - anchorAmount + ) + + // Checking Charlie's sat and asset balances in channel Charlie->Dave. + assertChannelSatBalance( + t.t, charlie, chanPointCD, + balancedLocalAmount-assetHtlcCarryAmount, assetHtlcCarryAmount, + ) + assertChannelAssetBalance( + t.t, charlie, chanPointCD, + charlieFundingAmount-charliePaidAmount, charliePaidAmount, + ) + + // Checking Dave's sat and asset balances in channel Charlie->Dave. + assertChannelSatBalance( + t.t, dave, chanPointCD, + assetHtlcCarryAmount, balancedLocalAmount-assetHtlcCarryAmount, + ) + assertChannelAssetBalance( + t.t, dave, chanPointCD, + charliePaidAmount, charlieFundingAmount-charliePaidAmount, + ) + + // Checking Dave's sat balance in channel Dave->Erin. + forwardAmountDave := addRoutingFee( + lnwire.MilliSatoshi(decodedInvoice.NumMsat), + ).ToSatoshis() + assertChannelSatBalance( + t.t, dave, chanPointDE, + btcChannelFundingAmount-commitFeeP2WSH-2*anchorAmount- + int64(forwardAmountDave), + int64(forwardAmountDave), + ) + + // Checking Erin's sat balance in channel Dave->Erin. + assertChannelSatBalance( + t.t, erin, chanPointDE, + int64(forwardAmountDave), + btcChannelFundingAmount-commitFeeP2WSH-2*anchorAmount- + int64(forwardAmountDave), + ) + + // Checking Erin's sat and asset balances in channel Erin->Fabia. + assertChannelSatBalance( + t.t, erin, chanPointEF, + balancedLocalAmount-assetHtlcCarryAmount, assetHtlcCarryAmount, + ) + assertChannelAssetBalance( + t.t, erin, chanPointEF, + erinFundingAmount-fabiaInvoiceAssetAmount, + fabiaInvoiceAssetAmount, + ) + + // Checking Fabia's sat and asset balances in channel Erin->Fabia. + assertChannelSatBalance( + t.t, fabia, chanPointEF, + assetHtlcCarryAmount, balancedLocalAmount-assetHtlcCarryAmount, + ) + assertChannelAssetBalance( + t.t, erin, chanPointEF, + fabiaInvoiceAssetAmount, + erinFundingAmount-fabiaInvoiceAssetAmount, + ) + + t.Logf("Closing Charlie -> Dave channel") + closeAssetChannelAndAssert( + t, net, charlie, dave, chanPointCD, assetID, nil, universeTap, + noOpCoOpCloseBalanceCheck, + ) + + t.Logf("Closing Dave -> Yara channel, close initiated by Yara") + closeAssetChannelAndAssert( + t, net, yara, dave, chanPointDY, assetID, nil, universeTap, + noOpCoOpCloseBalanceCheck, + ) + + t.Logf("Closing Erin -> Fabia channel") + closeAssetChannelAndAssert( + t, net, erin, fabia, chanPointEF, assetID, nil, universeTap, + noOpCoOpCloseBalanceCheck, + ) +} + +// testCustomChannelsFee tests whether the custom channel funding process +// fails if the proposed fee rate is lower than the minimum relay fee. +func testCustomChannelsFee(_ context.Context, + net *NetworkHarness, t *harnessTest) { + + ctxb := context.Background() + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplate) + + 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(), + )) + + charlie, err := net.NewNode( + t.t, "Charlie", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + dave, err := net.NewNode(t.t, "Dave", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + + nodes := []*HarnessNode{charlie, dave} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + charlieTap := newTapClient(t.t, charlie) + daveTap := newTapClient(t.t, dave) + + // Mint an assets on Charlie and sync Dave to Charlie as the universe. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, charlieTap, + []*mintrpc.MintAssetRequest{ + { + Asset: itestAsset, + }, + }, + ) + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + + t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) + syncUniverses(t.t, charlieTap, dave) + t.Logf("Universes synced between all nodes, distributing assets...") + + // Fund a channel with a fee rate of zero. + zeroFeeRate := uint32(0) + + _, err = charlieTap.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: cents.Amount, + AssetId: assetID, + PeerPubkey: daveTap.node.PubKey[:], + FeeRateSatPerVbyte: zeroFeeRate, + PushSat: 0, + }, + ) + + errSpecifyFeerate := "fee rate must be specified" + require.ErrorContains(t.t, err, errSpecifyFeerate) + + // Fund a channel with a fee rate that is too low. + tooLowFeeRate := uint32(1) + tooLowFeeRateAmount := chainfee.SatPerVByte(tooLowFeeRate) + + _, err = charlieTap.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: cents.Amount, + AssetId: assetID, + PeerPubkey: daveTap.node.PubKey[:], + FeeRateSatPerVbyte: tooLowFeeRate, + PushSat: 0, + }, + ) + + errFeeRateTooLow := fmt.Sprintf("fee rate %s too low, "+ + "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(ctxb context.Context, net *NetworkHarness, + t *harnessTest) { + + runCustomChannelsHtlcForceClose(ctxb, t, net, false) + runCustomChannelsHtlcForceClose(ctxb, t, net, true) +} + +// runCustomChannelsHtlcForceClose is a helper function that runs the HTLC force +// close test with the given MPP setting. +func runCustomChannelsHtlcForceClose(ctxb context.Context, t *harnessTest, + net *NetworkHarness, mpp bool) { + + t.Logf("Running test with MPP: %v", mpp) + + 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...") + 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 = 2_500 + ) + for i := 0; i < numPayments; i++ { + sendAssetKeySendPayment( + t.t, alice, bob, keySendAmount, assetID, + fn.None[int64](), + ) + } + + // 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 (or +4 with MPP), and two + // incoming HTLCs. + var ( + bobHodlInvoices []assetHodlInvoice + aliceHodlInvoices []assetHodlInvoice + + // The default oracle rate is 17_180 mSat/asset unit, so 10_000 + // will be equal to 171_800_000 mSat. When we use the mpp bool + // for the smallShards param of payInvoiceWithAssets, that + // means we'll split the payment into shards of 80_000_000 mSat + // max. So we'll get three shards per payment. + assetInvoiceAmt = 10_000 + assetsPerMPPShard = 4656 + ) + 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 { + opts := []payOpt{ + withFailure( + lnrpc.Payment_IN_FLIGHT, + lnrpc.PaymentFailureReason_FAILURE_REASON_NONE, + ), + } + if mpp { + opts = append(opts, withSmallShards()) + } + payInvoiceWithAssets( + t.t, bob, alice, aliceInvoice.payReq, assetID, opts..., + ) + } + for _, bobInvoice := range bobHodlInvoices { + payInvoiceWithAssets( + t.t, alice, bob, bobInvoice.payReq, assetID, + withFailure( + lnrpc.Payment_IN_FLIGHT, + lnrpc.PaymentFailureReason_FAILURE_REASON_NONE, + ), + ) + } + + // At this point, both sides should have 4 (or +4 with MPP) HTLCs + // active. + numHtlcs := 4 + if mpp { + numAdditionalShards := assetInvoiceAmt / assetsPerMPPShard + numHtlcs += numAdditionalShards * 2 + } + assertNumHtlcs(t.t, alice, numHtlcs) + assertNumHtlcs(t.t, bob, numHtlcs) + + // 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 from Alice to Bob + // * 2x incoming HTLCs from Bob to Alice (+2 with MPP) + // + // 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) + + bobSweepTx1, 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. + bobSweepTx2, 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, *bobSweepTx1[0]) + bobSweepTransfer2 := locateAssetTransfers(t.t, bobTap, *bobSweepTx2[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) + t.Logf("Expecting Bob's balance to be %d", bobExpectedBalance) + 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) + + aliceSweepTx1, err := waitForNTxsInMempool( + net.Miner.Client, 1, shortTimeout, + ) + require.NoError(t.t, err) + + mineBlocks(t, net, 1, 1) + + aliceSweepTransfer1 := locateAssetTransfers( + t.t, aliceTap, *aliceSweepTx1[0], + ) + t.Logf("Alice's sweep transfer 1: %v", + toProtoJSON(t.t, aliceSweepTransfer1)) + + 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 += 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 { + sweepTx, err := waitForNTxsInMempool( + net.Miner.Client, 1, shortTimeout, + ) + require.NoError(t.t, err) + + // With the sweep transaction in the mempool, we'll mine a block + // to confirm the sweep. + mineBlocks(t, net, 1, 1) + + aliceSweepTransfer := locateAssetTransfers( + t.t, aliceTap, *sweepTx[0], + ) + t.Logf("Alice's first-level sweep transfer: %v", + toProtoJSON(t.t, aliceSweepTransfer)) + } else { + sweepTx := sweepBlocks[0].Transactions[1] + aliceSweepTransfer := locateAssetTransfers( + t.t, aliceTap, sweepTx.TxHash(), + ) + t.Logf("Alice's first-level sweep transfer: %v", + toProtoJSON(t.t, aliceSweepTransfer)) + } + + 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, + //nolint: lll + 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 { + sweepTx, err := waitForNTxsInMempool( + net.Miner.Client, 1, shortTimeout, + ) + require.NoError(t.t, err) + + mineBlocks(t, net, 1, 1) + + aliceSweepTransfer := locateAssetTransfers( + t.t, aliceTap, *sweepTx[0], + ) + t.Logf("Alice's second-level sweep transfer: %v", + toProtoJSON(t.t, aliceSweepTransfer)) + } else { + sweepTx := sweepBlocks[0].Transactions[1] + aliceSweepTransfer := locateAssetTransfers( + t.t, aliceTap, sweepTx.TxHash(), + ) + t.Logf("Alice's second-level sweep transfer: %v", + toProtoJSON(t.t, aliceSweepTransfer)) + } + + // 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 { + sweepTx, err := waitForNTxsInMempool( + net.Miner.Client, 1, shortTimeout, + ) + require.NoError(t.t, err) + + // We'll mine one final block which will confirm Alice's sweep + // transaction. + mineBlocks(t, net, 1, 1) + + aliceSweepTransfer := locateAssetTransfers( + t.t, aliceTap, *sweepTx[0], + ) + t.Logf("Alice's final timeout sweep transfer: %v", + toProtoJSON(t.t, aliceSweepTransfer)) + } else { + sweepTx := sweepBlocks[0].Transactions[1] + aliceSweepTransfer := locateAssetTransfers( + t.t, aliceTap, sweepTx.TxHash(), + ) + t.Logf("Alice's final timeout sweep transfer: %v", + toProtoJSON(t.t, aliceSweepTransfer)) + } + + // Finally, we'll assert that Alice's balance has been incremented by + // the timeout value. + aliceExpectedBalance += uint64(assetInvoiceAmt - 1) + t.Logf("Expecting Alice's balance to be %d", aliceExpectedBalance) + 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, + ) +} + +// testCustomChannelsForwardBandwidth is a test that runs through some Taproot +// Assets Channel liquidity edge cases, specifically related to forwarding HTLCs +// into channels with no available asset bandwidth. +func testCustomChannelsForwardBandwidth(ctxb context.Context, + net *NetworkHarness, t *harnessTest) { + + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplate) + + // Explicitly set the proof courier as Zane (now has no other role + // other than proof shuffling), otherwise a hashmail courier will be + // used. For the funding transaction, we're just posting it and don't + // expect a true receiver. + 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(), + )) + + // The topology we are going for looks like the following: + // + // Charlie --[assets]--> Dave --[sats]--> Erin --[assets]--> Fabia + // | + // | + // [assets] + // | + // v + // Yara + // + // With [assets] being a custom channel and [sats] being a normal, BTC + // only channel. + // All 5 nodes need to be full litd nodes running in integrated mode + // with tapd included. We also need specific flags to be enabled, so we + // create 5 completely new nodes, ignoring the two default nodes that + // are created by the harness. + charlie, err := net.NewNode( + t.t, "Charlie", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + dave, err := net.NewNode(t.t, "Dave", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + erin, err := net.NewNode(t.t, "Erin", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + fabia, err := net.NewNode( + t.t, "Fabia", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + yara, err := net.NewNode( + t.t, "Yara", lndArgs, false, true, litdArgs..., + ) + require.NoError(t.t, err) + + nodes := []*HarnessNode{charlie, dave, erin, fabia, yara} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + // Create the normal channel between Dave and Erin. + t.Logf("Opening normal channel between Dave and Erin...") + channelOp := openChannelAndAssert( + t, net, dave, erin, lntest.OpenChannelParams{ + Amt: 10_000_000, + SatPerVByte: 5, + }, + ) + defer closeChannelAndAssert(t, net, dave, channelOp, false) + + // This is the only public channel, we need everyone to be aware of it. + assertChannelKnown(t.t, charlie, channelOp) + assertChannelKnown(t.t, fabia, channelOp) + + universeTap := newTapClient(t.t, zane) + charlieTap := newTapClient(t.t, charlie) + daveTap := newTapClient(t.t, dave) + erinTap := newTapClient(t.t, erin) + fabiaTap := newTapClient(t.t, fabia) + yaraTap := newTapClient(t.t, yara) + + // Mint an asset on Charlie and sync all nodes to Charlie as the + // universe. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, charlieTap, + []*mintrpc.MintAssetRequest{ + { + Asset: itestAsset, + }, + }, + ) + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + + t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) + syncUniverses(t.t, charlieTap, dave, erin, fabia, yara) + t.Logf("Universes synced between all nodes, distributing assets...") + + const ( + daveFundingAmount = uint64(400_000) + erinFundingAmount = uint64(200_000) + ) + charlieFundingAmount := cents.Amount - uint64(2*400_000) + + _, _, chanPointEF := createTestAssetNetwork( + t, net, charlieTap, daveTap, erinTap, fabiaTap, yaraTap, + universeTap, cents, 400_000, charlieFundingAmount, + daveFundingAmount, erinFundingAmount, 0, + ) + + // 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(charlie, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, charlie)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(dave, yara)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(yara, dave)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(erin, fabia)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(fabia, erin)) + require.NoError(t.t, t.lndHarness.AssertNodeKnown(charlie, erin)) + + logBalance(t.t, nodes, assetID, "initial") + + // We now deplete the channel between Erin and Fabia by moving all + // assets to Fabia. + sendAssetKeySendPayment( + t.t, erin, fabia, erinFundingAmount, assetID, fn.None[int64](), + ) + logBalance(t.t, nodes, assetID, "after moving assets to Fabia") + + // Test case 1: We cannot keysend more assets from Erin to Fabia. + sendAssetKeySendPayment( + t.t, erin, fabia, 1, assetID, fn.None[int64](), + withFailure(lnrpc.Payment_FAILED, failureNoBalance), + ) + + // Test case 2: We cannot pay an invoice from Charlie to Fabia. + invoiceResp := createAssetInvoice(t.t, erin, fabia, 123, assetID) + payInvoiceWithSatoshi( + t.t, charlie, invoiceResp, + withFailure(lnrpc.Payment_FAILED, failureNoRoute), + ) + + // Test case 3: We now create an asset buy order for a normal amount of + // assets. We then "fake" an invoice referencing that buy order that + // is for an amount that is too small to be paid with a single asset + // unit. This should be handled gracefully and not lead to a crash. + // Ideally such an invoice shouldn't be created in the first place, but + // we want to make sure that the system doesn't crash in this case. + numUnits := uint64(10) + buyOrderResp, err := fabiaTap.RfqClient.AddAssetBuyOrder( + ctxb, &rfqrpc.AddAssetBuyOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: assetID, + }, + }, + AssetMaxAmt: numUnits, + Expiry: uint64( + time.Now().Add(time.Hour).Unix(), + ), + PeerPubKey: erin.PubKey[:], + TimeoutSeconds: 10, + }, + ) + require.NoError(t.t, err) + + quoteResp := buyOrderResp.Response + quote, ok := quoteResp.(*rfqrpc.AddAssetBuyOrderResponse_AcceptedQuote) + require.True(t.t, ok) + + // We calculate the milli-satoshi amount one below the equivalent of a + // single asset unit. + rate, err := oraclerpc.UnmarshalFixedPoint(&oraclerpc.FixedPoint{ + Coefficient: quote.AcceptedQuote.AskAssetRate.Coefficient, + Scale: quote.AcceptedQuote.AskAssetRate.Scale, + }) + require.NoError(t.t, err) + + oneUnit := uint64(1) + oneUnitFP := rfqmath.NewBigIntFixedPoint(oneUnit, 0) + oneUnitMilliSat := rfqmath.UnitsToMilliSatoshi(oneUnitFP, *rate) + + t.Logf("Got quote for %v asset units per BTC", rate) + msatPerUnit := float64(oneUnitMilliSat) / float64(oneUnit) + t.Logf("Got quote for %v asset units at %3f msat/unit from peer %s "+ + "with SCID %d", numUnits, msatPerUnit, erin.PubKeyStr, + quote.AcceptedQuote.Scid) + + // We now manually add the invoice in order to inject the above, + // manually generated, quote. + invoiceResp2, err := fabia.AddInvoice(ctxb, &lnrpc.Invoice{ + Memo: "too small invoice", + ValueMsat: int64(oneUnitMilliSat - 1), + RouteHints: []*lnrpc.RouteHint{{ + HopHints: []*lnrpc.HopHint{{ + NodeId: erin.PubKeyStr, + ChanId: quote.AcceptedQuote.Scid, + }}, + }}, + }) + require.NoError(t.t, err) + + payInvoiceWithSatoshi(t.t, dave, invoiceResp2, withFailure( + lnrpc.Payment_FAILED, failureNoRoute, + )) + + // Let's make sure we can still use the channel between Erin and Fabia + // by doing a satoshi keysend payment. + sendKeySendPayment(t.t, erin, fabia, 2000) + logBalance(t.t, nodes, assetID, "after BTC only keysend") + + // Finally, we close the channel between Erin and Fabia to make sure + // everything is settled correctly. + closeAssetChannelAndAssert( + t, net, erin, fabia, chanPointEF, assetID, nil, + universeTap, noOpCoOpCloseBalanceCheck, + ) +} + +// testCustomChannelsDecodeAssetInvoice tests that we're able to properly +// decode and display asset invoice related information. +// +// TODO(roasbeef): just move to tapd repo due to new version that doesn't req a +// chan? +func testCustomChannelsDecodeAssetInvoice(ctx context.Context, + net *NetworkHarness, t *harnessTest) { + + // First, we'll set up some information for our custom oracle that we'll use + // to feed in price information. + oracleAddr := fmt.Sprintf("localhost:%d", port.NextAvailablePort()) + oracle := newOracleHarness(oracleAddr) + oracle.start(t.t) + t.t.Cleanup(oracle.stop) + + ctxb := context.Background() + lndArgs := slices.Clone(lndArgsTemplate) + litdArgs := slices.Clone(litdArgsTemplateNoOracle) + litdArgs = append(litdArgs, fmt.Sprintf( + "--taproot-assets.experimental.rfq.priceoracleaddress="+ + "rfqrpc://%s", oracleAddr, + )) + + // For this test, Zane will be our dedicated Universe server for all parties. + 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(), + )) + + // We'll just make a single node here, as this doesn't actually rely on a set + // of active channels. + alice, err := net.NewNode(t.t, "Alice", lndArgs, false, true, litdArgs...) + require.NoError(t.t, err) + aliceTap := newTapClient(t.t, alice) + + // Fund Alice so she'll have enough funds to mint the asset. + fundAllNodes(t.t, net, []*HarnessNode{alice}) + + // Next, we'll make a new asset with a specified decimal display. We'll also + // make grouped asset as well. + usdMetaData := &taprpc.AssetMeta{ + Data: []byte(`{ +"description":"this is a USD stablecoin with decimal display of 6" +}`), + Type: taprpc.AssetMetaType_META_TYPE_JSON, + } + + const decimalDisplay = 6 + itestAsset = &mintrpc.MintAsset{ + AssetType: taprpc.AssetType_NORMAL, + Name: "USD", + AssetMeta: usdMetaData, + // We mint 1 million USD with a decimal display of 6, which + // results in 1 trillion asset units. + Amount: 1_000_000_000_000, + DecimalDisplay: decimalDisplay, + NewGroupedAsset: true, + } + + // Mint an asset on Charlie and sync Dave to Charlie as the universe. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, aliceTap, + []*mintrpc.MintAssetRequest{ + { + Asset: itestAsset, + }, + }, + ) + usdAsset := mintedAssets[0] + assetID := usdAsset.AssetGenesis.AssetId + + // Now that we've minted the asset, we can set the price in the oracle. + var id asset.ID + copy(id[:], assetID) + + // We'll assume a price of $100,000.00 USD for a single BTC. This is just the + // current subjective price our oracle will use. From this BTC price, we'll + // scale things up to be in the precision of the asset we minted above. + btcPrice := rfqmath.NewBigIntFixedPoint( + 100_000_00, 2, + ) + factor := rfqmath.NewBigInt( + big.NewInt(int64(math.Pow10(decimalDisplay))), + ) + btcPrice.Coefficient = btcPrice.Coefficient.Mul(factor) + oracle.setPrice(id, btcPrice, btcPrice) + + // Now we'll make a normal invoice for 1 BTC using Alice. + expirySeconds := 10 + amountSat := 100_000_000 + invoiceResp, err := alice.AddInvoice(ctxb, &lnrpc.Invoice{ + Value: int64(amountSat), + Memo: "normal invoice", + Expiry: int64(expirySeconds), + }) + require.NoError(t.t, err) + + payReq := invoiceResp.PaymentRequest + + // Now that we have our payment request, we'll call into the new decode asset + // pay req call. + decodeResp, err := aliceTap.DecodeAssetPayReq(ctxb, &tapchannelrpc.AssetPayReq{ + AssetId: assetID, + PayReqString: payReq, + }) + require.NoError(t.t, err) + + // The decimal display information, genesis, and asset group information + // should all match. + require.Equal( + t.t, int64(decimalDisplay), int64(decodeResp.DecimalDisplay.DecimalDisplay), + ) + require.Equal(t.t, usdAsset.AssetGenesis, decodeResp.GenesisInfo) + require.Equal(t.t, usdAsset.AssetGroup, decodeResp.AssetGroup) + + // The 1 BTC invoice should map to 100k asset units, with decimal display 6 + // that's 100 billion asset units. + const expectedUnits = 100_000_000_000 + require.Equal(t.t, int64(expectedUnits), int64(decodeResp.AssetAmount)) +} diff --git a/itest/litd_node.go b/itest/litd_node.go index 7ec9a029..e9be5597 100644 --- a/itest/litd_node.go +++ b/itest/litd_node.go @@ -89,6 +89,9 @@ type LitNodeConfig struct { LitPort int LitRESTPort int + + // backupDBDir is the path where a database backup is stored, if any. + backupDBDir string } func (cfg *LitNodeConfig) LitAddr() string { @@ -2087,3 +2090,38 @@ func connectLitRPC(ctx context.Context, hostPort, tlsCertPath, return grpc.DialContext(ctx, hostPort, opts...) } + +// copyAll copies all files and directories from srcDir to dstDir recursively. +// Note that this function does not support links. +func copyAll(dstDir, srcDir string) error { + entries, err := os.ReadDir(srcDir) + if err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(srcDir, entry.Name()) + dstPath := filepath.Join(dstDir, entry.Name()) + + info, err := os.Stat(srcPath) + if err != nil { + return err + } + + if info.IsDir() { + err := os.Mkdir(dstPath, info.Mode()) + if err != nil && !os.IsExist(err) { + return err + } + + err = copyAll(dstPath, srcPath) + if err != nil { + return err + } + } else if err := CopyFile(dstPath, srcPath); err != nil { + return err + } + } + + return nil +} diff --git a/itest/litd_test.go b/itest/litd_test.go index a87a0a42..2736baf6 100644 --- a/itest/litd_test.go +++ b/itest/litd_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/btcsuite/btclog" "github.com/lightningnetwork/lnd/build" "github.com/lightningnetwork/lnd/lntest" "github.com/lightningnetwork/lnd/signal" @@ -56,7 +57,8 @@ func TestLightningTerminal(t *testing.T) { // Start a chain backend. chainBackend, _, err := lntest.NewBackend( - lndHarness.Miner().P2PAddress(), harnessNetParams, + lndHarness.Miner().P2PAddress(), + harnessNetParams, ) require.NoError(t1, err, "new backend") @@ -130,6 +132,10 @@ func (h *harnessTest) setupLogging() { require.NoError(h.t, err) interceptor = &ic + UseLogger(build.NewSubLogger(Subsystem, func(tag string) btclog.Logger { + return logWriter.GenSubLogger(tag, func() {}) + })) + err = build.ParseAndSetDebugLevels("debug", logWriter) require.NoError(h.t, err) } diff --git a/itest/litd_test_list_on_test.go b/itest/litd_test_list_on_test.go index 1ddc5a2d..6e104fe6 100644 --- a/itest/litd_test_list_on_test.go +++ b/itest/litd_test_list_on_test.go @@ -24,4 +24,56 @@ var allTestCases = []*testCase{ name: "test large http header", test: testLargeHttpHeader, }, + { + name: "test custom channels", + test: testCustomChannels, + }, + { + name: "test custom channels large", + test: testCustomChannelsLarge, + }, + { + name: "test custom channels grouped asset", + test: testCustomChannelsGroupedAsset, + }, + { + name: "test custom channels force close", + test: testCustomChannelsForceClose, + }, + { + name: "test custom channels breach", + test: testCustomChannelsBreach, + }, + { + name: "test custom channels liquidity", + test: testCustomChannelsLiquidityEdgeCases, + }, + { + name: "test custom channels htlc force close", + test: testCustomChannelsHtlcForceClose, + }, + { + name: "test custom channels balance consistency", + test: testCustomChannelsBalanceConsistency, + }, + { + name: "test custom channels single asset multi input", + test: testCustomChannelsSingleAssetMultiInput, + }, + { + name: "test custom channels oracle pricing", + test: testCustomChannelsOraclePricing, + }, + { + name: "test custom channels fee", + test: testCustomChannelsFee, + }, + { + name: "test custom channels forward bandwidth", + test: testCustomChannelsForwardBandwidth, + }, + { + name: "test custom channels decode payreq", + test: testCustomChannelsDecodeAssetInvoice, + }, } diff --git a/itest/log.go b/itest/log.go new file mode 100644 index 00000000..67211af5 --- /dev/null +++ b/itest/log.go @@ -0,0 +1,24 @@ +package itest + +import ( + "github.com/btcsuite/btclog" + "github.com/lightningnetwork/lnd/build" +) + +const Subsystem = "ITST" + +// log is a logger that is initialized with no output filters. This means the +// package will not perform any logging by default until the caller requests it. +var log btclog.Logger + +// The default amount of logging is none. +func init() { + UseLogger(build.NewSubLogger(Subsystem, nil)) +} + +// UseLogger uses a specified Logger to output package logging info. +// This should be used in preference to SetLogWriter if the caller is also +// using btclog. +func UseLogger(logger btclog.Logger) { + log = logger +} diff --git a/itest/network_harness.go b/itest/network_harness.go index 27116344..5b562c5e 100644 --- a/itest/network_harness.go +++ b/itest/network_harness.go @@ -46,7 +46,8 @@ type NetworkHarness struct { // Miner is a reference to a running full node that can be used to create // new blocks on the network. - Miner *miner.HarnessMiner + Miner *miner.HarnessMiner + LNDHarness *lntest.HarnessTest // server is an instance of the local Loop/Pool mock server. @@ -435,6 +436,12 @@ tryconnect: "finish syncing") } } + + // Ignore "already connected to peer" errors. + if strings.Contains(err.Error(), "already connected to peer") { + return nil + } + return err } @@ -767,6 +774,58 @@ func (n *NetworkHarness) StopNode(node *HarnessNode) error { return node.Stop() } +// StopAndBackupDB backs up the database of the target node. +func (n *NetworkHarness) StopAndBackupDB(node *HarnessNode) error { + restart, err := n.SuspendNode(node) + if err != nil { + return err + } + + // Backup files. + tempDir, err := os.MkdirTemp("", "past-state") + if err != nil { + return fmt.Errorf("unable to create temp db folder: %w", + err) + } + + if err := copyAll(tempDir, node.Cfg.DBDir()); err != nil { + return fmt.Errorf("unable to copy database files: %w", + err) + } + + node.Cfg.backupDBDir = tempDir + + return restart() +} + +// StopAndRestoreDB stops the target node, restores the database from a backup +// and starts the node again. +func (n *NetworkHarness) StopAndRestoreDB(node *HarnessNode) error { + restart, err := n.SuspendNode(node) + if err != nil { + return err + } + + // Restore files. + if node.Cfg.backupDBDir == "" { + return fmt.Errorf("no database backup created") + } + + err = copyAll(node.Cfg.DBDir(), node.Cfg.backupDBDir) + if err != nil { + return fmt.Errorf("unable to copy database files: %w", + err) + } + + if err := os.RemoveAll(node.Cfg.backupDBDir); err != nil { + return fmt.Errorf("unable to remove backup dir: %w", + err) + } + node.Cfg.backupDBDir = "" + + return restart() +} + // OpenChannel attempts to open a channel between srcNode and destNode with the // passed channel funding parameters. If the passed context has a timeout, then // if the timeout is reached before the channel pending notification is @@ -1053,8 +1112,11 @@ func (n *NetworkHarness) CloseChannel(lnNode *HarnessNode, closeReq := &lnrpc.CloseChannelRequest{ ChannelPoint: cp, Force: force, - SatPerVbyte: 5, } + if !force { + closeReq.SatPerVbyte = 5 + } + closeRespStream, err = lnNode.CloseChannel(ctx, closeReq) if err != nil { return fmt.Errorf("unable to close channel: %v", err) @@ -1097,7 +1159,8 @@ func (n *NetworkHarness) CloseChannel(lnNode *HarnessNode, // passed context has a timeout, then if the timeout is reached before the // notification is received then an error is returned. func (n *NetworkHarness) WaitForChannelClose( - closeChanStream lnrpc.Lightning_CloseChannelClient) (*chainhash.Hash, error) { + stream lnrpc.Lightning_CloseChannelClient) (*lnrpc.ChannelCloseUpdate, + error) { ctxb := context.Background() ctx, cancel := context.WithTimeout(ctxb, wait.ChannelCloseTimeout) @@ -1106,13 +1169,14 @@ func (n *NetworkHarness) WaitForChannelClose( errChan := make(chan error) updateChan := make(chan *lnrpc.CloseStatusUpdate_ChanClose) go func() { - closeResp, err := closeChanStream.Recv() + closeResp, err := stream.Recv() if err != nil { errChan <- err return } - closeFin, ok := closeResp.Update.(*lnrpc.CloseStatusUpdate_ChanClose) + update := closeResp.Update + closeFin, ok := update.(*lnrpc.CloseStatusUpdate_ChanClose) if !ok { errChan <- fmt.Errorf("expected channel close update, "+ "instead got %v", closeFin) @@ -1130,7 +1194,7 @@ func (n *NetworkHarness) WaitForChannelClose( case err := <-errChan: return nil, err case update := <-updateChan: - return chainhash.NewHash(update.ChanClose.ClosingTxid) + return update.ChanClose, nil } } @@ -1177,6 +1241,33 @@ func (n *NetworkHarness) AssertChannelExists(node *HarnessNode, }, lntest.DefaultTimeout) } +// AssertNodeKnown makes sure the given node knows about the target node in the +// network graph. +func (n *NetworkHarness) AssertNodeKnown(node, target *HarnessNode) error { + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, wait.DefaultTimeout) + defer cancel() + + req := &lnrpc.NodeInfoRequest{ + PubKey: hex.EncodeToString( + target.PubKey[:], + ), + } + return wait.NoError(func() error { + info, err := node.GetNodeInfo(ctxt, req) + if err != nil { + return err + } + + if info.Node == nil { + return fmt.Errorf("node %x has no info about %x", + node.PubKey[:], target.PubKey[:]) + } + + return nil + }, lntest.DefaultTimeout) +} + // DumpLogs reads the current logs generated by the passed node, and returns // the logs as a single string. This function is useful for examining the logs // of a particular node in the case of a test failure. diff --git a/itest/oracle_test.go b/itest/oracle_test.go new file mode 100644 index 00000000..8f7cfd0c --- /dev/null +++ b/itest/oracle_test.go @@ -0,0 +1,279 @@ +package itest + +import ( + "context" + "crypto/tls" + "encoding/hex" + "fmt" + "net" + "testing" + "time" + + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/rfqmath" + "github.com/lightninglabs/taproot-assets/rfqmsg" + oraclerpc "github.com/lightninglabs/taproot-assets/taprpc/priceoraclerpc" + "github.com/lightningnetwork/lnd/cert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +// oracleHarness is a basic integration test RPC price oracle server harness. +type oracleHarness struct { + oraclerpc.UnimplementedPriceOracleServer + + listenAddr string + + grpcListener net.Listener + grpcServer *grpc.Server + + purchasePrices map[asset.ID]rfqmath.BigIntFixedPoint + salePrices map[asset.ID]rfqmath.BigIntFixedPoint +} + +func newOracleHarness(listenAddr string) *oracleHarness { + return &oracleHarness{ + listenAddr: listenAddr, + purchasePrices: make(map[asset.ID]rfqmath.BigIntFixedPoint), + salePrices: make(map[asset.ID]rfqmath.BigIntFixedPoint), + } +} + +func (o *oracleHarness) setPrice(assetID asset.ID, purchasePrice, + salePrice rfqmath.BigIntFixedPoint) { + + o.purchasePrices[assetID] = purchasePrice + o.salePrices[assetID] = salePrice +} + +func (o *oracleHarness) start(t *testing.T) { + // Start the mock RPC price oracle service. + // + // Generate self-signed certificate. This allows us to use TLS for the + // gRPC server. + tlsCert, err := generateSelfSignedCert() + require.NoError(t, err) + + // Create the gRPC server with TLS + transportCredentials := credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + }) + o.grpcServer = grpc.NewServer(grpc.Creds(transportCredentials)) + + serviceAddr := fmt.Sprintf("rfqrpc://%s", o.listenAddr) + log.Infof("Starting RPC price oracle service at address: %s\n", + serviceAddr) + + oraclerpc.RegisterPriceOracleServer(o.grpcServer, o) + + go func() { + var err error + o.grpcListener, err = net.Listen("tcp", o.listenAddr) + if err != nil { + log.Errorf("Error oracle listening: %v", err) + return + } + if err := o.grpcServer.Serve(o.grpcListener); err != nil { + log.Errorf("Error oracle serving: %v", err) + } + }() +} + +func (o *oracleHarness) stop() { + if o.grpcServer != nil { + o.grpcServer.Stop() + } + if o.grpcListener != nil { + _ = o.grpcListener.Close() + } +} + +// getAssetRates returns the asset rates for a given transaction type and +// subject asset max amount. +func (o *oracleHarness) getAssetRates(id asset.ID, + transactionType oraclerpc.TransactionType) (oraclerpc.AssetRates, + error) { + + // Determine the rate based on the transaction type. + var subjectAssetRate rfqmath.BigIntFixedPoint + if transactionType == oraclerpc.TransactionType_PURCHASE { + rate, ok := o.purchasePrices[id] + if !ok { + return oraclerpc.AssetRates{}, fmt.Errorf("purchase "+ + "price not found for asset ID=%v", id) + } + subjectAssetRate = rate + } else { + rate, ok := o.salePrices[id] + if !ok { + return oraclerpc.AssetRates{}, fmt.Errorf("sale "+ + "price not found for asset ID=%v", id) + } + subjectAssetRate = rate + } + + // Marshal subject asset rate to RPC format. + rpcSubjectAssetToBtcRate, err := oraclerpc.MarshalBigIntFixedPoint( + subjectAssetRate, + ) + if err != nil { + return oraclerpc.AssetRates{}, err + } + + // Marshal payment asset rate to RPC format. + rpcPaymentAssetToBtcRate, err := oraclerpc.MarshalBigIntFixedPoint( + rfqmsg.MilliSatPerBtc, + ) + if err != nil { + return oraclerpc.AssetRates{}, err + } + + expiry := time.Now().Add(5 * time.Minute).Unix() + return oraclerpc.AssetRates{ + SubjectAssetRate: rpcSubjectAssetToBtcRate, + PaymentAssetRate: rpcPaymentAssetToBtcRate, + ExpiryTimestamp: uint64(expiry), + }, nil +} + +// QueryAssetRates queries the asset rates for a given transaction type, subject +// asset, and payment asset. An asset rate is the number of asset units per +// BTC. +// +// Example use case: +// +// Alice is trying to pay an invoice by spending an asset. Alice therefore +// requests that Bob (her asset channel counterparty) purchase the asset from +// her. Bob's payment, in BTC, will pay the invoice. +// +// Alice requests a bid quote from Bob. Her request includes an asset rates hint +// (ask). Alice obtains the asset rates hint by calling this endpoint. She sets: +// - `SubjectAsset` to the asset she is trying to sell. +// - `SubjectAssetMaxAmount` to the max channel asset outbound. +// - `PaymentAsset` to BTC. +// - `TransactionType` to SALE. +// - `AssetRateHint` to nil. +// +// Bob calls this endpoint to get the bid quote asset rates that he will send as +// a response to Alice's request. He sets: +// - `SubjectAsset` to the asset that Alice is trying to sell. +// - `SubjectAssetMaxAmount` to the value given in Alice's quote request. +// - `PaymentAsset` to BTC. +// - `TransactionType` to PURCHASE. +// - `AssetRateHint` to the value given in Alice's quote request. +func (o *oracleHarness) QueryAssetRates(_ context.Context, + req *oraclerpc.QueryAssetRatesRequest) ( + *oraclerpc.QueryAssetRatesResponse, error) { + + // Ensure that the payment asset is BTC. We only support BTC as the + // payment asset in this example. + if !oraclerpc.IsAssetBtc(req.PaymentAsset) { + log.Infof("Payment asset is not BTC: %v", req.PaymentAsset) + + return &oraclerpc.QueryAssetRatesResponse{ + Result: &oraclerpc.QueryAssetRatesResponse_Error{ + Error: &oraclerpc.QueryAssetRatesErrResponse{ + Message: "unsupported payment asset, " + + "only BTC is supported", + }, + }, + }, nil + } + + // Ensure that the subject asset is set correctly. + subjectAssetID, err := parseSubjectAsset(req.SubjectAsset) + if err != nil { + log.Errorf("Error parsing subject asset: %v", err) + return nil, fmt.Errorf("error parsing subject asset: %w", err) + } + + _, hasPurchase := o.purchasePrices[subjectAssetID] + _, hasSale := o.salePrices[subjectAssetID] + + log.Infof("Have for asset=%x, purchase=%v, sale=%v", subjectAssetID[:], + hasPurchase, hasSale) + + // Ensure that the subject asset is supported. + if !hasPurchase || !hasSale { + log.Infof("Unsupported subject asset ID str: %v\n", + req.SubjectAsset) + + return &oraclerpc.QueryAssetRatesResponse{ + Result: &oraclerpc.QueryAssetRatesResponse_Error{ + Error: &oraclerpc.QueryAssetRatesErrResponse{ + Message: "unsupported subject asset", + }, + }, + }, nil + } + + assetRates, err := o.getAssetRates(subjectAssetID, req.TransactionType) + if err != nil { + return nil, err + } + + log.Infof("QueryAssetRates returning rates (subject_asset_rate=%v, "+ + "payment_asset_rate=%v)", assetRates.SubjectAssetRate, + assetRates.PaymentAssetRate) + + return &oraclerpc.QueryAssetRatesResponse{ + Result: &oraclerpc.QueryAssetRatesResponse_Ok{ + Ok: &oraclerpc.QueryAssetRatesOkResponse{ + AssetRates: &assetRates, + }, + }, + }, nil +} + +// parseSubjectAsset parses the subject asset from the given asset specifier. +func parseSubjectAsset(subjectAsset *oraclerpc.AssetSpecifier) (asset.ID, + error) { + + // Ensure that the subject asset is set. + if subjectAsset == nil { + return asset.ID{}, fmt.Errorf("subject asset is not set (nil)") + } + + // Check the subject asset bytes if set. + var subjectAssetID asset.ID + switch { + case len(subjectAsset.GetAssetId()) > 0: + copy(subjectAssetID[:], subjectAsset.GetAssetId()) + + case len(subjectAsset.GetAssetIdStr()) > 0: + assetIDBytes, err := hex.DecodeString( + subjectAsset.GetAssetIdStr(), + ) + if err != nil { + return asset.ID{}, fmt.Errorf("error decoding asset "+ + "ID hex string: %w", err) + } + + copy(subjectAssetID[:], assetIDBytes) + + default: + return asset.ID{}, fmt.Errorf("subject asset ID bytes and ID " + + "str not set") + } + + return subjectAssetID, nil +} + +// generateSelfSignedCert generates a self-signed TLS certificate and private +// key. +func generateSelfSignedCert() (tls.Certificate, error) { + certBytes, keyBytes, err := cert.GenCertPair( + "itest price oracle", nil, nil, false, 24*time.Hour, + ) + if err != nil { + return tls.Certificate{}, err + } + + tlsCert, err := tls.X509KeyPair(certBytes, keyBytes) + if err != nil { + return tls.Certificate{}, err + } + + return tlsCert, nil +}