From 73ffe8506c37092e1474e60452ec30e6b5ec2b6b Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 22 Apr 2026 16:57:48 +0200 Subject: [PATCH 1/2] itest: add chancloser aux output regression --- itest/custom_channels/custom_channels_test.go | 4 + itest/custom_channels/fee_test.go | 88 ++++++++++++++ itest/custom_channels/helpers.go | 114 ++++++++++++++++++ 3 files changed, 206 insertions(+) diff --git a/itest/custom_channels/custom_channels_test.go b/itest/custom_channels/custom_channels_test.go index bbf46a8732..3f24573e8a 100644 --- a/itest/custom_channels/custom_channels_test.go +++ b/itest/custom_channels/custom_channels_test.go @@ -107,6 +107,10 @@ var testCases = []*ccTestCase{ name: "fee", test: testCustomChannelsFee, }, + { + name: "coop close fee baseline", + test: testCustomChannelsCoopCloseFeeBaseline, + }, { name: "breach", test: testCustomChannelsBreach, diff --git a/itest/custom_channels/fee_test.go b/itest/custom_channels/fee_test.go index 97712b8683..9dd4ddbe82 100644 --- a/itest/custom_channels/fee_test.go +++ b/itest/custom_channels/fee_test.go @@ -9,8 +9,10 @@ import ( "github.com/lightninglabs/taproot-assets/itest" "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/taprpc" "github.com/lightninglabs/taproot-assets/taprpc/mintrpc" tchrpc "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lntest/node" "github.com/lightningnetwork/lnd/lntest/port" "github.com/lightningnetwork/lnd/lnwallet/chainfee" @@ -100,3 +102,89 @@ func testCustomChannelsFee(ctx context.Context, "min_relay_fee: ", tooLowFeeRateAmount.FeePerKWeight()) require.ErrorContains(t.t, err, errFeeRateTooLow) } + +// testCustomChannelsCoopCloseFeeBaseline is a regression test for lnd +// cooperative close fee estimation with auxiliary close outputs. Closing at +// relay floor must still succeed once the aux outputs are included in the +// initial fee baseline. +func testCustomChannelsCoopCloseFeeBaseline(ctx context.Context, + net *itest.IntegratedNetworkHarness, t *ccHarnessTest) { + + lndArgs := slices.Clone(lndArgsTemplate) + tapdArgs := slices.Clone(tapdArgsTemplate) + + // We use Charlie as the proof courier. But in order for Charlie to + // also use itself, we need to define its port upfront. + charliePort := port.NextAvailablePort() + tapdArgs = append(tapdArgs, fmt.Sprintf( + "--proofcourieraddr=%s://%s", + proof.UniverseRpcCourierType, + fmt.Sprintf(node.ListenerFormat, charliePort), + )) + + charlieLndArgs := slices.Clone(lndArgs) + charlieLndArgs = append(charlieLndArgs, fmt.Sprintf( + "--rpclisten=127.0.0.1:%d", charliePort, + )) + + charlie := net.NewNode("Charlie", charlieLndArgs, tapdArgs) + dave := net.NewNode("Dave", lndArgs, tapdArgs) + + nodes := []*itest.IntegratedNode{charlie, dave} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + // Mint an asset on Charlie and sync Dave to Charlie as the universe. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, net.Miner, asTapd(charlie), + []*mintrpc.MintAssetRequest{ + { + Asset: ccItestAsset, + }, + }, + ) + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + + t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) + syncUniverses(t.t, charlie, dave) + t.Logf("Universes synced between all nodes, distributing assets...") + + const ( + openFeeRateSatPerVbyte = 5 + closeFeeRateSatPerVbyte = 1 + ) + + net.FeeService.SetMinRelayFeerate( + chainfee.SatPerVByte(closeFeeRateSatPerVbyte).FeePerKVByte(), + ) + + assetFundResp, err := asTapd(charlie).FundChannel( + ctx, &tchrpc.FundChannelRequest{ + AssetAmount: fundingAmount, + AssetId: assetID, + PeerPubkey: dave.PubKey[:], + FeeRateSatPerVbyte: openFeeRateSatPerVbyte, + PushSat: 0, + }, + ) + require.NoError(t.t, err) + + mineBlocks(t, net, 6, 1) + + assertAssetChan( + t.t, charlie, dave, fundingAmount, []*taprpc.Asset{cents}, + ) + + chanPoint := &lnrpc.ChannelPoint{ + OutputIndex: uint32(assetFundResp.OutputIndex), + FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: assetFundResp.Txid, + }, + } + closeAssetChannelWithFeeAndAssert( + t, net, charlie, dave, chanPoint, closeFeeRateSatPerVbyte, + [][]byte{assetID}, nil, charlie, + assertDefaultCoOpCloseBalance(false, false), + ) +} diff --git a/itest/custom_channels/helpers.go b/itest/custom_channels/helpers.go index e863138eed..79ad181b4c 100644 --- a/itest/custom_channels/helpers.go +++ b/itest/custom_channels/helpers.go @@ -1456,6 +1456,120 @@ func closeAssetChannelAndAssert(t *ccHarnessTest, assertClosedChannelAssetData(t.t, remote, chanPoint) } +// closeAssetChannelWithFeeAndAssert closes an asset channel at the given fee +// rate and asserts the final balances. +func closeAssetChannelWithFeeAndAssert(t *ccHarnessTest, + net *itest.IntegratedNetworkHarness, + local, remote *itest.IntegratedNode, + chanPoint *lnrpc.ChannelPoint, feeRateSatPerVbyte uint64, + assetIDs [][]byte, groupKey []byte, + universeTap *itest.IntegratedNode, + balanceCheck coOpCloseBalanceCheck) { + + t.t.Helper() + + // Ensure the two parties are connected before attempting the close. + // Channel closes after other close operations can sometimes race with + // peer disconnection, causing "peer is offline" errors. + net.EnsureConnected(t.t, local, remote) + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, wait.DefaultTimeout) + defer cancel() + + closeReq := &lnrpc.CloseChannelRequest{ + ChannelPoint: chanPoint, + SatPerVbyte: feeRateSatPerVbyte, + } + closeStream, err := local.CloseChannel(ctxb, closeReq) + require.NoError(t.t, err) + + err = waitForClosePendingUpdate(t, net, closeStream) + require.NoError(t.t, err) + + sendEvents, err := local.SubscribeSendEvents( + ctxt, &taprpc.SubscribeSendEventsRequest{}, + ) + require.NoError(t.t, err) + + assertWaitingCloseChannelAssetData(t.t, local, chanPoint) + assertWaitingCloseChannelAssetData(t.t, remote, chanPoint) + + mineBlocks(t, net, 1, 1) + + closeUpdate, err := net.WaitForChannelClose(closeStream) + require.NoError(t.t, err) + + closeTxid, err := chainhash.NewHash(closeUpdate.ClosingTxid) + require.NoError(t.t, err) + + closeTransaction := net.Miner.GetRawTransaction(*closeTxid) + closeTx := closeTransaction.MsgTx() + t.Logf("Channel closed with txid: %v", closeTxid) + + waitForSendEvent(t.t, sendEvents, tapfreighter.SendStateComplete) + + balanceCheck( + t.t, local, remote, closeTx, closeUpdate, assetIDs, groupKey, + universeTap, + ) + + assertClosedChannelAssetData(t.t, local, chanPoint) + assertClosedChannelAssetData(t.t, remote, chanPoint) +} + +// waitForClosePendingUpdate waits for the first close pending update on the +// close stream and ensures that the close transaction reaches the mempool. +func waitForClosePendingUpdate(t *ccHarnessTest, + net *itest.IntegratedNetworkHarness, + closeStream lnrpc.Lightning_CloseChannelClient) error { + + t.t.Helper() + + errChan := make(chan error, 1) + txidChan := make(chan *chainhash.Hash, 1) + + go func() { + closeResp, err := closeStream.Recv() + if err != nil { + errChan <- fmt.Errorf("unable to recv from close stream: %w", + err) + return + } + + pendingClose, ok := closeResp.Update.(*lnrpc.CloseStatusUpdate_ClosePending) + if !ok { + errChan <- fmt.Errorf("expected close pending update, "+ + "instead got %v", closeResp) + return + } + + closeTxid, err := chainhash.NewHash( + pendingClose.ClosePending.Txid, + ) + if err != nil { + errChan <- fmt.Errorf("unable to decode closeTxid: %w", + err) + return + } + + txidChan <- closeTxid + }() + + select { + case err := <-errChan: + return err + + case closeTxid := <-txidChan: + net.Miner.AssertTxInMempool(*closeTxid) + return nil + + case <-time.After(wait.ChannelCloseTimeout): + return fmt.Errorf("timeout reached while waiting for close " + + "pending update") + } +} + // assertDefaultCoOpCloseBalance returns a default co-op close balance check // function. // From d25dda6cac3b9309877c3b32d097cf49458eb26a Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 22 Apr 2026 17:35:36 +0200 Subject: [PATCH 2/2] itest: relax coop close regression assertion --- itest/custom_channels/fee_test.go | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/itest/custom_channels/fee_test.go b/itest/custom_channels/fee_test.go index 9dd4ddbe82..f4bbbd1144 100644 --- a/itest/custom_channels/fee_test.go +++ b/itest/custom_channels/fee_test.go @@ -6,7 +6,9 @@ import ( "context" "fmt" "slices" + "testing" + "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/taproot-assets/itest" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/taprpc" @@ -182,9 +184,25 @@ func testCustomChannelsCoopCloseFeeBaseline(ctx context.Context, FundingTxidStr: assetFundResp.Txid, }, } + + feeBaselineCoOpCloseBalanceCheck := func(t *testing.T, _, _ *itest.IntegratedNode, + closeTx *wire.MsgTx, closeUpdate *lnrpc.ChannelCloseUpdate, + _ [][]byte, _ []byte, _ *itest.IntegratedNode) { + + require.NotNil(t, closeUpdate.LocalCloseOutput) + require.Len(t, closeUpdate.AdditionalOutputs, 1) + + localAuxOut := closeUpdate.AdditionalOutputs[0] + require.True(t, localAuxOut.IsLocal) + + auxTxOut, _ := findTxOut(t, closeTx, localAuxOut.PkScript) + require.LessOrEqual(t, auxTxOut.Value, int64(1000)) + + _, _ = findTxOut(t, closeTx, closeUpdate.LocalCloseOutput.PkScript) + } + closeAssetChannelWithFeeAndAssert( t, net, charlie, dave, chanPoint, closeFeeRateSatPerVbyte, - [][]byte{assetID}, nil, charlie, - assertDefaultCoOpCloseBalance(false, false), + [][]byte{assetID}, nil, charlie, feeBaselineCoOpCloseBalanceCheck, ) }