faraday/itest/test_context.go
bitromortac c5d7147ab0
itest: add channel events pruning test
Add an integration test to verify the safety pruning behavior under various
conditions.
2026-06-25 13:53:26 +02:00

625 lines
17 KiB
Go

package itest
import (
"context"
"fmt"
"os/exec"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/rpcclient"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/faraday"
"github.com/lightninglabs/faraday/frdrpc"
"github.com/lightninglabs/lndclient"
invoicespkg "github.com/lightningnetwork/lnd/invoices"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
)
var (
waitDuration = 20 * time.Second
waitTick = 200 * time.Millisecond
processKillTimeout = 5 * time.Second
faradayCmd = "./faraday"
faradayCertPath = "/root/.faraday/regtest/tls.cert"
faradayMacaroonPath = "/root/.faraday/regtest/faraday.macaroon"
faradayArgs = []string{
"--rpclisten=localhost:8465",
"--network=regtest",
"--lnd.macaroonpath=lnd-alice/faraday-custom.macaroon",
"--lnd.tlscertpath=lnd-alice/tls.cert",
"--debuglevel=debug",
"--connect_bitcoin",
"--bitcoin.user=devuser",
"--bitcoin.password=devpass",
"--bitcoin.host=localhost:18443",
}
)
// testContext provides a set up test environment for itests to run in.
type testContext struct {
bitcoindClient *rpcclient.Client
aliceClient *lndclient.GrpcLndServices
bobClient *lndclient.GrpcLndServices
faradayClient frdrpc.FaradayServerClient
alicePubkey, bobPubkey route.Vertex
dummyAddress btcutil.Address
faradayCmd *exec.Cmd
faradayErr chan error
t *testing.T
}
// newTestContext returns a new context instance.
func newTestContext(t *testing.T, extraFaradayArgs ...string) *testContext {
var err error
ctx := &testContext{
t: t,
}
// Setup rpc client for bitcoind.
ctx.bitcoindClient, err = getBitcoindClient()
require.NoError(t, err)
// Set a dummy mining address.
ctx.dummyAddress, err = btcutil.DecodeAddress(
"2N9kBLwWmJjoPxBddwR8G9hwLMrQyHum44K",
&chaincfg.RegressionNetParams,
)
require.NoError(t, err)
// Setup rpc client for lnd instances.
ctx.aliceClient, err = lndclient.NewLndServices(
&lndclient.LndServicesConfig{
LndAddress: "localhost:10009",
Network: lndclient.NetworkRegtest,
MacaroonDir: "lnd-alice/data/chain/bitcoin/regtest",
TLSPath: "lnd-alice/tls.cert",
CheckVersion: faraday.MinLndVersion,
},
)
require.NoError(t, err)
ctx.bobClient, err = lndclient.NewLndServices(
&lndclient.LndServicesConfig{
LndAddress: "localhost:10002",
Network: lndclient.NetworkRegtest,
MacaroonDir: "lnd-bob/data/chain/bitcoin/regtest",
TLSPath: "lnd-bob/tls.cert",
CheckVersion: faraday.MinLndVersion,
},
)
require.NoError(t, err)
// Get lnd instance info.
aliceInfo, err := ctx.aliceClient.Client.GetInfo(context.Background())
require.NoError(t, err)
ctx.alicePubkey, err = route.NewVertexFromBytes(aliceInfo.IdentityPubkey[:])
require.NoError(t, err)
bobInfo, err := ctx.bobClient.Client.GetInfo(context.Background())
require.NoError(t, err)
ctx.bobPubkey, err = route.NewVertexFromBytes(bobInfo.IdentityPubkey[:])
require.NoError(t, err)
// Start faraday.
ctx.startFaraday(extraFaradayArgs...)
// Wait for faraday's channel events monitor to finish its initial
// chain-sync.
time.Sleep(5 * time.Second)
return ctx
}
// mine signals btcd to mine the given number of blocks.
func (c *testContext) mineBlocks(blocks uint32) {
_, err := c.bitcoindClient.GenerateToAddress(
int64(blocks), c.dummyAddress, nil,
)
require.NoError(c.t, err)
}
// mine mines a block and returns the number of included txes.
func (c *testContext) mine() int {
c.t.Helper()
blockHashes, err := c.bitcoindClient.GenerateToAddress(
1, c.dummyAddress, nil,
)
require.NoError(c.t, err)
hash := blockHashes[0]
block, err := c.bitcoindClient.GetBlock(hash)
require.NoError(c.t, err)
// Subtract coinbase tx.
return len(block.Transactions) - 1
}
// mempoolTxCount returns the number of txes currently in the mempool.
func (c *testContext) mempoolTxCount() int {
txes, err := c.bitcoindClient.GetRawMempool()
require.NoError(c.t, err)
return len(txes)
}
// balances stores the wallet and channel balances for alice and bob.
type balances struct {
aliceWallet, aliceChannel btcutil.Amount
bobWallet, bobChannel btcutil.Amount
}
// String returns human-readable balances
func (b balances) String() string {
return fmt.Sprintf(
"alice: wallet=%v,channel=%v, bob: wallet=%v,channel=%v",
b.aliceWallet, b.aliceChannel,
b.bobWallet, b.bobChannel,
)
}
// getBalances returns the balances for the client and server lnd instances.
func (c *testContext) getBalances() balances {
get := func(lnd lndclient.LightningClient) (btcutil.Amount,
btcutil.Amount) {
walletResp, err := lnd.WalletBalance(
context.Background(),
)
require.NoError(c.t, err)
channelResp, err := lnd.WalletBalance(
context.Background(),
)
require.NoError(c.t, err)
return walletResp.Confirmed, channelResp.Confirmed
}
var b balances
b.aliceWallet, b.aliceChannel = get(c.aliceClient.Client)
b.bobWallet, b.bobChannel = get(c.bobClient.Client)
return b
}
// waitBalance keeps querying the lnd balances until the given condition is met.
func (c *testContext) waitBalance(condition func(balances) bool) {
c.t.Helper()
c.eventuallyf(
func() bool {
return condition(c.getBalances())
},
"timeout waiting for balance",
)
}
// eventuallyf wraps testify's Eventuallyf method with default time parameters.
func (c *testContext) eventuallyf(condition func() bool, msg string,
args ...interface{}) { // nolint:unparam
c.t.Helper()
require.Eventuallyf(
c.t, condition, waitDuration, waitTick, msg, args,
)
}
func (c *testContext) addInvoice(client lndclient.LightningClient,
amt lnwire.MilliSatoshi) (lntypes.Hash, string) {
// Add an invoice to the receiving client.
hash, payreq, err := client.AddInvoice(
context.Background(), &invoicesrpc.AddInvoiceData{
Value: amt,
},
)
require.NoError(c.t, err, "could not add invoice")
return hash, payreq
}
// makePayment takes a source and destination node and makes a payment of the
// amount provided from the source to the destination node. Liquidity should be
// sufficiently balanced to make the payment. The function returns the payment
// preimage used and the total fees paid by the sender.
func (c *testContext) makePayment(src, dest lndclient.LndServices,
payreq lndclient.SendPaymentRequest,
targetState lnrpc.Payment_PaymentStatus) lntypes.Preimage {
paymentChan, errChan, err := src.Router.SendPayment(
context.Background(), payreq,
)
require.NoError(c.t, err, "could not send payment")
// Wait for the payment to report as a success on the payer's side.
var preimage lntypes.Preimage
c.eventuallyf(func() bool {
select {
case status := <-paymentChan:
preimage = status.Preimage
return status.State == targetState
case <-errChan:
require.NoError(c.t, err, "error sending payment")
return false
}
}, "payment did not reach: %v", targetState)
// Wait for the recipient to settle the invoice if our target state was
// to settle the payment.
if targetState == lnrpc.Payment_SUCCEEDED {
c.eventuallyf(func() bool {
inv, err := dest.Client.LookupInvoice(
context.Background(), *payreq.PaymentHash,
)
if err != nil {
return false
}
return inv.State == invoicespkg.ContractSettled
}, "payment not received")
}
return preimage
}
// closeChannel closes a channel, mines blocks until the channel is completely
// resolved and returns the closed txid.
func (c *testContext) closeChannel(client lndclient.LightningClient,
channel *wire.OutPoint, force bool) (chainhash.Hash, btcutil.Amount) {
closeChan, errChan, err := client.CloseChannel(
context.Background(), channel, force, 0, nil,
)
require.NoError(c.t, err, "could not close channel")
var (
closeTx chainhash.Hash
closeFee btcutil.Amount
gotPending bool
)
// Wait for us to get an update from our channel indicating that it is
// pending close.
c.eventuallyf(func() bool {
select {
case update := <-closeChan:
closeTx = update.CloseTxid()
switch update.(type) {
case *lndclient.PendingCloseUpdate:
// Get our close tx from the mempool to get its fee
// and add an expected entry because we opened the
// channel so we pay the fees. This must happen
// before we mine any block, otherwise the close tx
// is confirmed out of the mempool and the lookup
// fails.
close, err := c.bitcoindClient.GetMempoolEntry(
closeTx.String(),
)
require.NoError(c.t, err, "could not get mempool")
closeFee, err = btcutil.NewAmount(close.Fee)
require.NoError(c.t, err, "could not get fee")
gotPending = true
case *lndclient.ChannelClosedUpdate:
return true
}
case <-errChan:
c.t.Fatalf("error closing channel: %v, %v", channel,
err)
// If we have not received an update yet, wait for the pending
// close to broadcast. Only once we have captured the close tx
// fee from the mempool do we start mining blocks to drive the
// channel to its fully resolved state, so that mining does not
// confirm the close tx before we read its fee.
default:
if gotPending {
c.mine()
}
}
return false
}, "channel did not enter pending close state")
// Now, we want to wait to for our channel to resolve. This can take
// much longer than getting a pending close, because our force closed
// channels need to wait for their outputs to unlock. We use a custom
// timeout in this case.
waitForClose := func() bool {
channels, err := client.ClosedChannels(context.Background())
require.NoError(c.t, err, "could not get closed channels")
for _, channel := range channels {
if channel.ClosingTxHash == closeTx.String() {
return true
}
}
// If we did not find our channel, mine another block then
// return.
c.mine()
return false
}
require.Eventuallyf(
c.t, waitForClose, time.Minute*2, waitTick,
"could not resolve force closed channel: %v", channel,
)
return closeTx, closeFee
}
// waitForWalletsSynced waits for both nodes to report their wallets as synced
// to the graph.
func (c *testContext) waitForWalletsSynced() {
c.eventuallyf(func() bool {
info, err := c.aliceClient.Client.GetInfo(context.Background())
if err != nil {
return false
}
if !info.SyncedToGraph {
return false
}
info, err = c.bobClient.Client.GetInfo(context.Background())
if err != nil {
return false
}
return info.SyncedToGraph
}, "wallets never synced to chain")
}
// openChannel opens a channel and waits for both sides to see it.
func (c *testContext) openChannel(src lndclient.LightningClient,
destKey route.Vertex, amount btcutil.Amount) (*wire.OutPoint,
btcutil.Amount) {
// Wait for both of our nodes to report that their wallets are synced
// so that we can open a channel between them.
c.waitForWalletsSynced()
channel, err := src.OpenChannel(
context.Background(), destKey, amount, 0, false,
)
require.NoError(c.t, err, "could not open channel")
// Once we have initiated opening node's channel, we get it from the
// mempool so that we can get our tx fee.
c.waitForMempoolTxCount(1, "channel not in mempool")
tx, err := c.bitcoindClient.GetMempoolEntry(channel.Hash.String())
require.NoError(c.t, err, "could not get mempool")
fee, err := btcutil.NewAmount(tx.Fee)
require.NoError(c.t, err, "channel fee failed")
// Wait for both nodes to see the channel.
c.waitForChannelOpen(channel)
return channel, fee
}
// waitForChannelOpen waits for a channel between alice and bob to become
// active.
func (c *testContext) waitForChannelOpen(targetChannel *wire.OutPoint) {
c.t.Helper()
c.eventuallyf(
func() bool {
c.mine()
aliceChans, err := c.aliceClient.Client.ListChannels(
context.Background(), false, false,
)
require.NoError(c.t, err)
// If we did not find our target channel, we fail.
if findChannel(aliceChans, targetChannel) == nil {
return false
}
bobChans, err := c.bobClient.Client.ListChannels(
context.Background(), false, false,
)
require.NoError(c.t, err)
// Succeed if we found our channel in in bob's channels.
return findChannel(bobChans, targetChannel) != nil
},
"channel not open",
)
}
// channelRoutable reports whether alice's router can build a route to dest
// for amount. It gates on QueryRoutes rather than channel activation: lnd
// marks a channel Active on channel_ready, but the local channel_update the
// router needs lands a moment later.
func (c *testContext) channelRoutable(dest route.Vertex,
amount lnwire.MilliSatoshi) bool {
_, err := c.aliceClient.Client.QueryRoutes(
context.Background(), lndclient.QueryRoutesRequest{
PubKey: dest,
AmtMsat: amount,
},
)
return err == nil
}
// disconnectPeer disconnects the given client from a peer, taking any channels
// between them offline. lnd normally refuses to disconnect from a peer with an
// active channel, but the itest lnd is a non-integration build where unsafe
// disconnect is always permitted. The raw lnrpc client is used because the
// high-level lndclient interface exposes no Disconnect, and the admin macaroon
// is attached at call time since the shared connection carries none.
func (c *testContext) disconnectPeer(client *lndclient.GrpcLndServices,
peer route.Vertex) {
c.t.Helper()
ctx, err := client.WithMacaroonAuthForService(
context.Background(), lndclient.AdminServiceMac,
)
require.NoError(c.t, err, "could not attach macaroon")
raw := lnrpc.NewLightningClient(client.ClientConn)
_, err = raw.DisconnectPeer(ctx, &lnrpc.DisconnectPeerRequest{
PubKey: peer.String(),
})
require.NoError(c.t, err, "could not disconnect peer")
}
// channelEventCounts returns how many online and offline events faraday has
// recorded for the given channel up to the present.
func (c *testContext) channelEventCounts(chanPoint string) (online,
offline int) {
c.t.Helper()
endTime := time.Now().Add(time.Second).Unix()
events, err := c.faradayClient.GetChannelEvents(
context.Background(), &frdrpc.ChannelEventsRequest{
ChanPoint: chanPoint,
EndTime: endTime,
},
)
require.NoError(c.t, err, "could not get channel events")
for _, event := range events.Events {
switch event.EventType {
case frdrpc.ChannelEventType_CHAN_EVENT_ONLINE:
online++
case frdrpc.ChannelEventType_CHAN_EVENT_OFFLINE:
offline++
}
}
return online, offline
}
// findChannel finds a channel in a set of open channels, returning nil if it
// is not found.
// nolint:interfacer
func findChannel(channels []lndclient.ChannelInfo,
target *wire.OutPoint) *lndclient.ChannelInfo {
for _, channel := range channels {
if channel.ChannelPoint == target.String() {
// Declare a variable in our scope so we don't return
// a pointer to a range variable.
foundChannel := channel
return &foundChannel
}
}
return nil
}
// waitForMempoolTxCount waits until the specified number of txes are present in
// the mempool
func (c *testContext) waitForMempoolTxCount(txCount int, msg string) {
c.t.Helper()
c.eventuallyf(
func() bool {
return c.mempoolTxCount() == txCount
},
msg,
)
}
// startFaraday starts faraday, connecting to our test context's alice lnd node.
// It returns process start errors and an error channel for errors that occur
// after the start.
func (c *testContext) startFaraday(extraArgs ...string) {
args := append([]string{}, faradayArgs...)
args = append(args, extraArgs...)
// Start loop client daemon.
c.faradayCmd = exec.Command(
faradayCmd, args...,
)
attachPrefixStdout(c.faradayCmd, "faraday")
log.Info("Starting Faraday")
require.NoError(c.t, c.faradayCmd.Start())
c.faradayErr = make(chan error, 1)
go func() {
c.faradayErr <- c.faradayCmd.Wait()
}()
// Setup connection to faraday.
var err error
c.eventuallyf(func() bool {
c.faradayClient, err = getFaradayClient(
"localhost:8465", faradayCertPath, faradayMacaroonPath,
)
return err == nil
}, "could not connect to faraday process: %v", err)
// Wait for connectivity.
c.eventuallyf(func() bool {
_, err = c.faradayClient.ChannelInsights(
context.Background(), &frdrpc.ChannelInsightsRequest{},
)
return err == nil
}, "could not connect to faraday process: %v", err)
}
// stopFaraday stops the faraday process.
func (c *testContext) stopFaraday() {
if c.faradayCmd == nil {
return
}
// Kill the faraday process.
require.NoError(c.t, c.faradayCmd.Process.Kill())
select {
case <-c.faradayErr:
case <-time.After(processKillTimeout):
require.FailNow(c.t, "cannot kill faraday process")
}
c.faradayCmd = nil
}
// stop stops the faraday process.
func (c *testContext) stop() {
c.stopFaraday()
}