lightning-terminal/itest/assets_test.go
Oliver Gugger 23039a92b0
itest: add custom channel integration test
Co-authored-by: Olaoluwa Osuntokun <laolu32@gmail.com>
Co-authored-by: Gijs van Dam <gijs@lightning.engineering>
Co-authored-by: George Tsagkarelis <george.tsagkarelis@gmail.com>
2024-12-19 21:21:39 +01:00

2120 lines
58 KiB
Go

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,
}
}