diff --git a/cmd/tapd-integrated/main.go b/cmd/tapd-integrated/main.go index 893e8f8bfe..b4ae590310 100644 --- a/cmd/tapd-integrated/main.go +++ b/cmd/tapd-integrated/main.go @@ -15,6 +15,7 @@ import ( "os" "path/filepath" "sync" + "time" "github.com/btcsuite/btclog/v2" "github.com/jessevdk/go-flags" @@ -235,6 +236,7 @@ func run() error { BlockUntilChainSynced: true, BlockUntilUnlocked: true, CallerCtx: ctx, + RPCTimeout: 2 * time.Minute, } if cfg.Lnd.NoMacaroons { // Use a dummy macaroon that lndclient can deserialize. diff --git a/go.mod b/go.mod index a8890aab49..21723bf281 100644 --- a/go.mod +++ b/go.mod @@ -220,3 +220,7 @@ replace github.com/lightninglabs/taproot-assets/taprpc => ./taprpc // Needed for healthcheck import. replace github.com/prometheus/common => github.com/prometheus/common v0.26.0 + +replace github.com/lightningnetwork/lnd => github.com/GeorgeTsagk/lnd v0.0.0-20260309152417-4cd51dc8fd46 + +replace github.com/lightningnetwork/lnd/sqldb => github.com/GeorgeTsagk/lnd/sqldb v0.0.0-20260309152417-4cd51dc8fd46 diff --git a/go.sum b/go.sum index b0e6fbf147..de8aba8315 100644 --- a/go.sum +++ b/go.sum @@ -603,6 +603,10 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/GeorgeTsagk/lnd v0.0.0-20260309152417-4cd51dc8fd46 h1:Drzxi5iQ7rKsIyg8GWPm2ZWJc3pvb7U4EzgG0t4k4pc= +github.com/GeorgeTsagk/lnd v0.0.0-20260309152417-4cd51dc8fd46/go.mod h1:fpkIKYZQaqvW3uEIqk5D97V0s84H5xeduhxYlSq9HmE= +github.com/GeorgeTsagk/lnd/sqldb v0.0.0-20260309152417-4cd51dc8fd46 h1:Aw/yIjtesLNaL+s5tAE5GWeGgiyfeqFNsuDY063ZKb4= +github.com/GeorgeTsagk/lnd/sqldb v0.0.0-20260309152417-4cd51dc8fd46/go.mod h1:XaG3d8AR7/e6+HUw5jvNvm+gs6MowB+iE9myFH8Rc14= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= @@ -1114,8 +1118,6 @@ github.com/lightninglabs/protobuf-go-hex-display v1.34.2-hex-display h1:w7FM5LH9 github.com/lightninglabs/protobuf-go-hex-display v1.34.2-hex-display/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= github.com/lightningnetwork/lightning-onion v1.2.1-0.20240815225420-8b40adf04ab9 h1:6D3LrdagJweLLdFm1JNodZsBk6iU4TTsBBFLQ4yiXfI= github.com/lightningnetwork/lightning-onion v1.2.1-0.20240815225420-8b40adf04ab9/go.mod h1:EDqJ3MuZIbMq0QI1czTIKDJ/GS8S14RXPwapHw8cw6w= -github.com/lightningnetwork/lnd v0.20.0-beta.rc4.0.20260305102707-7c38c1ea0572 h1:peXc+YcS6Ufa4rVSMj+kCiBSUw0YTizKrbgQdCL87eo= -github.com/lightningnetwork/lnd v0.20.0-beta.rc4.0.20260305102707-7c38c1ea0572/go.mod h1:fpkIKYZQaqvW3uEIqk5D97V0s84H5xeduhxYlSq9HmE= github.com/lightningnetwork/lnd/cert v1.2.2 h1:71YK6hogeJtxSxw2teq3eGeuy4rHGKcFf0d0Uy4qBjI= github.com/lightningnetwork/lnd/cert v1.2.2/go.mod h1:jQmFn/Ez4zhDgq2hnYSw8r35bqGVxViXhX6Cd7HXM6U= github.com/lightningnetwork/lnd/clock v1.1.1 h1:OfR3/zcJd2RhH0RU+zX/77c0ZiOnIMsDIBjgjWdZgA0= @@ -1128,8 +1130,6 @@ github.com/lightningnetwork/lnd/kvdb v1.4.16 h1:9BZgWdDfjmHRHLS97cz39bVuBAqMc4/p github.com/lightningnetwork/lnd/kvdb v1.4.16/go.mod h1:HW+bvwkxNaopkz3oIgBV6NEnV4jCEZCACFUcNg4xSjM= github.com/lightningnetwork/lnd/queue v1.1.1 h1:99ovBlpM9B0FRCGYJo6RSFDlt8/vOkQQZznVb18iNMI= github.com/lightningnetwork/lnd/queue v1.1.1/go.mod h1:7A6nC1Qrm32FHuhx/mi1cieAiBZo5O6l8IBIoQxvkz4= -github.com/lightningnetwork/lnd/sqldb v1.0.13-0.20260305102707-7c38c1ea0572 h1:oZG5q1sIhtRN9XcvbGhrb6GTj1y0uB49swjfNO5L0sE= -github.com/lightningnetwork/lnd/sqldb v1.0.13-0.20260305102707-7c38c1ea0572/go.mod h1:XaG3d8AR7/e6+HUw5jvNvm+gs6MowB+iE9myFH8Rc14= github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6ijJlbdiZFbSM= github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA= github.com/lightningnetwork/lnd/tlv v1.3.2 h1:MO4FCk7F4k5xPMqVZF6Nb/kOpxlwPrUQpYjmyKny5s0= diff --git a/itest/custom_channels/breach_test.go b/itest/custom_channels/breach_test.go index 454c56f776..dad9d0339a 100644 --- a/itest/custom_channels/breach_test.go +++ b/itest/custom_channels/breach_test.go @@ -6,9 +6,9 @@ import ( "context" "fmt" "slices" - "time" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/taproot-assets/itest" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/taprpc" @@ -17,17 +17,21 @@ import ( "github.com/lightninglabs/taproot-assets/tapscript" fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" "github.com/lightningnetwork/lnd/lntest/node" "github.com/lightningnetwork/lnd/lntest/port" + "github.com/lightningnetwork/lnd/lntest/wait" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) // testCustomChannelsBreach tests the breach/justice scenario for custom -// channels. Dave backs up his DB state, one more payment advances the state, -// then Dave restores the old state and force-closes (broadcasting a revoked -// commitment). Charlie detects the breach and sweeps both outputs, recovering -// all channel funds. +// channels with in-flight HTLCs. Dave backs up his DB state (which has active +// hodl invoice HTLCs), the HTLCs are settled to advance the state, then Dave +// restores the old state and force-closes (broadcasting a revoked commitment +// with HTLC outputs). Charlie is suspended during the breach so Dave can +// advance HTLCs to second level. Charlie is then resumed, detects the breach, +// and sweeps all outputs including second-level HTLC outputs. func testCustomChannelsBreach(ctx context.Context, net *itest.IntegratedNetworkHarness, t *ccHarnessTest) { @@ -37,16 +41,26 @@ func testCustomChannelsBreach(ctx context.Context, net.FeeService.SetFeeRate(chainfee.SatPerKWeight(1000), 1) lndArgs := slices.Clone(lndArgsTemplate) + lndArgs = append(lndArgs, "--bitcoin.defaultremotedelay=144") 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() + // Use Zane as a dedicated universe node that stays online as proof + // courier, so that Dave can still import proofs and advance HTLCs + // to second level even when Charlie is suspended. + // + // We allocate a port for Zane's RPC upfront and add it as an extra + // --rpclisten so the proof courier address is known before Zane + // starts (same pattern as core_test.go). + zanePort := port.NextAvailablePort() + zaneLndArgs := append(slices.Clone(lndArgs), fmt.Sprintf( + "--rpclisten=127.0.0.1:%d", zanePort, + )) tapdArgs = append(tapdArgs, fmt.Sprintf( "--proofcourieraddr=%s://%s", proof.UniverseRpcCourierType, - fmt.Sprintf(node.ListenerFormat, charliePort), + fmt.Sprintf(node.ListenerFormat, zanePort), )) + zane := net.NewNode("Zane", zaneLndArgs, tapdArgs) // Charlie will be the breached party. We set --nolisten to ensure // Dave won't be able to connect to him and trigger the channel @@ -56,9 +70,6 @@ func testCustomChannelsBreach(ctx context.Context, charlieLndArgs := append( slices.Clone(lndArgs), "--nolisten", "--minbackoff=1h", ) - charlieLndArgs = append(charlieLndArgs, fmt.Sprintf( - "--rpclisten=127.0.0.1:%d", charliePort, - )) // For this simple test, we'll just have Charlie -> Dave as an assets // channel. @@ -71,6 +82,11 @@ func testCustomChannelsBreach(ctx context.Context, connectAllNodes(t.t, net, nodes) fundAllNodes(t.t, net, nodes) + // Connect Zane to Dave directly, and have Charlie connect outbound + // to Zane (since Charlie has --nolisten and can't accept inbound). + net.EnsureConnected(t.t, zane, dave) + net.EnsureConnected(t.t, charlie, zane) + // Now we'll make an asset for Charlie that we'll use in the test to // open a channel. mintedAssets := itest.MintAssetsConfirmBatch( @@ -85,7 +101,7 @@ func testCustomChannelsBreach(ctx context.Context, assetID := cents.AssetGenesis.AssetId t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) - syncUniverses(t.t, charlie, dave) + syncUniverses(t.t, charlie, zane, dave) t.Logf("Universes synced between all nodes, distributing assets...") // Next we can open an asset channel from Charlie -> Dave, then kick @@ -119,12 +135,12 @@ func testCustomChannelsBreach(ctx context.Context, ) // Make sure that Charlie properly uploaded funding proof to the - // Universe server. + // Universe server (Zane is the proof courier). fundingScriptTree := tapscript.NewChannelFundingScriptTree() fundingScriptKey := fundingScriptTree.TaprootKey fundingScriptTreeBytes := fundingScriptKey.SerializeCompressed() assertUniverseProofExists( - t.t, charlie, assetID, nil, fundingScriptTreeBytes, + t.t, zane, assetID, nil, fundingScriptTreeBytes, fmt.Sprintf( "%v:%v", assetFundResp.Txid, assetFundResp.OutputIndex, @@ -141,12 +157,13 @@ func testCustomChannelsBreach(ctx context.Context, require.NoError(t.t, net.AssertNodeKnown(charlie, dave)) require.NoError(t.t, net.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. + // Next, we'll make keysend payments from Charlie to Dave. We use + // 10k sats per keysend so Dave has enough BTC to cover multiple + // in-flight HTLCs (at 6x dust each) plus channel reserve. const ( numPayments = 5 - keySendAmount = 100 - btcAmt = int64(5_000) + keySendAmount = 200 + btcAmt = int64(10_000) ) for i := 0; i < numPayments; i++ { sendAssetKeySendPayment( @@ -155,19 +172,107 @@ func testCustomChannelsBreach(ctx context.Context, ) } - logBalance(t.t, nodes, assetID, "after keysend -- breach state") + logBalance(t.t, nodes, assetID, "after keysend -- balanced state") + + // Now create hodl invoices on both sides to ensure HTLCs exist on + // the commitment we're about to backup. This tests the revoked HTLC + // sweep paths (TaprootHtlcOfferedRevoke, TaprootHtlcAcceptedRevoke). + const ( + numHodlInvoices = 2 + htlcAmount = 200 + ) + + var ( + daveHodlInvoices []assetHodlInvoice + charlieHodlInvoices []assetHodlInvoice + ) + + t.Logf("Creating %d hodl invoices per peer...", numHodlInvoices) + + // Create Dave's hodl invoices (Charlie pays = outgoing HTLCs). + for i := 0; i < numHodlInvoices; i++ { + daveHodlInvoices = append( + daveHodlInvoices, createAssetHodlInvoice( + t.t, charlie, dave, htlcAmount, assetID, + ), + ) + } + + // Create Charlie's hodl invoices (Dave pays = incoming HTLCs). + for i := 0; i < numHodlInvoices; i++ { + charlieHodlInvoices = append( + charlieHodlInvoices, createAssetHodlInvoice( + t.t, dave, charlie, htlcAmount, assetID, + ), + ) + } + + // Pay all invoices but don't settle (HTLCs stay in flight). + payOpt := withFailure( + lnrpc.Payment_IN_FLIGHT, + lnrpc.PaymentFailureReason_FAILURE_REASON_NONE, + ) + + t.Logf("Paying hodl invoices to create HTLCs on commitment...") + + for _, daveInv := range daveHodlInvoices { + payInvoiceWithAssets( + t.t, charlie, dave, daveInv.payReq, assetID, payOpt, + ) + } + + for _, charlieInv := range charlieHodlInvoices { + payInvoiceWithAssets( + t.t, dave, charlie, charlieInv.payReq, assetID, payOpt, + ) + } + + // Verify HTLCs are active on both sides. + expectedHtlcs := numHodlInvoices * 2 + assertNumHtlcs(t.t, charlie, expectedHtlcs) + assertNumHtlcs(t.t, dave, expectedHtlcs) + + logBalance(t.t, nodes, assetID, "after hodl invoices -- breach state") // Now we'll create an on disk snapshot that we'll use to restore - // back to as our breached state. + // back to as our breached state. This state has active HTLCs! 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. + // Settle all the hodl invoices to revoke the state with HTLCs. + // This will cause the backed-up state to become revoked, which + // will trigger the breach detection when Dave broadcasts it. + t.Logf("Settling hodl invoices to revoke breach state...") + + for _, daveInv := range daveHodlInvoices { + _, err := dave.InvoicesClient.SettleInvoice( + ctx, &invoicesrpc.SettleInvoiceMsg{ + Preimage: daveInv.preimage[:], + }, + ) + require.NoError(t.t, err) + } + + for _, charlieInv := range charlieHodlInvoices { + _, err := charlie.InvoicesClient.SettleInvoice( + ctx, &invoicesrpc.SettleInvoiceMsg{ + Preimage: charlieInv.preimage[:], + }, + ) + require.NoError(t.t, err) + } + + // Send one more keysend to ensure the state with settled HTLCs is + // committed and the previous state (with active HTLCs) is revoked. sendAssetKeySendPayment( t.t, charlie, dave, keySendAmount, assetID, fn.Some(btcAmt), ) - logBalance(t.t, nodes, assetID, "after keysend -- final state") + + // Wait for all HTLCs to clear. + assertNumHtlcs(t.t, charlie, 0) + assertNumHtlcs(t.t, dave, 0) + + logBalance(t.t, nodes, assetID, "after settling HTLCs -- final state") // With the final state achieved, we'll now restore Dave (who will // be force closing) to that old state, the breach state. @@ -181,43 +286,207 @@ func testCustomChannelsBreach(ctx context.Context, FundingTxidStr: assetFundResp.Txid, }, } + + // Suspend Charlie BEFORE the breach so Dave can advance HTLCs to + // second level without Charlie's justice tx interfering. + t.Logf("Suspending Charlie before breach...") + restartCharlie, err := net.SuspendNode(charlie) + require.NoError(t.t, err) + _, 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. + // 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. - locateAssetTransfers(t.t, charlie, *breachTxid) - locateAssetTransfers(t.t, dave, *breachTxid) - - // With the breach transaction mined, Charlie should now have a - // transaction in the mempool sweeping *both* commitment outputs. - // We use a generous timeout because Charlie needs to process the - // block, detect the breach, and construct the justice transaction. - charlieJusticeTxid, err := waitForNTxsInMempool( - net.Miner.Client, 1, time.Second*30, + // Mine blocks to let Dave's HTLC timeout resolvers advance HTLCs to + // second level. CLTV delta is 80, so we need ~100 blocks. We must + // NOT mine enough for the CSV delay (144) on second-level outputs + // to expire, or Dave will sweep them before Charlie can. + // + // We mine in small batches and check the mempool between batches to + // give Dave's sweeper time to broadcast second-level HTLC txs. + t.Logf("Mining blocks to let Dave advance HTLCs to 2nd level...") + var secondLevelTxns []*wire.MsgTx + breachHash := breachTxid + const ( + totalBlocks = 100 + batchSize = 10 ) + var allBlocks []*wire.MsgBlock + for mined := uint32(0); mined < totalBlocks; { + // Check mempool before mining the next batch. + mempool, mempoolErr := net.Miner.Client.GetRawMempool() + require.NoError(t.t, mempoolErr) + if len(mempool) > 0 { + t.Logf("Mempool has %d txns at height offset %d", + len(mempool), mined) + for _, txid := range mempool { + rawTx, txErr := net.Miner.Client. + GetRawTransaction(txid) + if txErr != nil { + continue + } + tx := rawTx.MsgTx() + for _, txIn := range tx.TxIn { + if txIn.PreviousOutPoint.Hash == + *breachHash { + + t.Logf("Found 2nd-level tx "+ + "%v in mempool "+ + "spending breach "+ + "output %d", + tx.TxHash(), + txIn.PreviousOutPoint.Index) + } + } + } + } + + // Mine a batch, including any mempool txs in first block. + n := batchSize + if mined+uint32(n) > totalBlocks { + n = int(totalBlocks - mined) + } + blocks := mineBlocks(t, net, uint32(n), len(mempool)) + allBlocks = append(allBlocks, blocks...) + mined += uint32(n) + + t.Logf("Mined %d/%d blocks", mined, totalBlocks) + } + + // Scan all mined blocks for second-level txns (txs spending from + // the breach transaction). + for _, block := range allBlocks { + for _, tx := range block.Transactions { + for _, txIn := range tx.TxIn { + if txIn.PreviousOutPoint.Hash == *breachHash { + t.Logf("Found 2nd-level tx %v "+ + "spending breach output %d", + tx.TxHash(), + txIn.PreviousOutPoint.Index) + + secondLevelTxns = append( + secondLevelTxns, tx, + ) + } + } + } + } + t.Logf("Found %d second-level txns total", len(secondLevelTxns)) + + // Log the breach tx outputs for reference. + breachTx, err := net.Miner.Client.GetRawTransaction(breachTxid) require.NoError(t.t, err) + for i, out := range breachTx.MsgTx().TxOut { + t.Logf("Breach output %d: value=%d pkscript=%x", + i, out.Value, out.PkScript) + } - t.Logf("Charlie justice txid: %v", charlieJusticeTxid) + // Log second-level tx details. + for i, tx := range secondLevelTxns { + for j, out := range tx.TxOut { + t.Logf("2nd-level tx %d output %d: value=%d "+ + "pkscript=%x", i, j, out.Value, out.PkScript) + } + } - // Next, we'll mine a block to confirm Charlie's justice transaction. - mineBlocks(t, net, 1, 1) + // Now resume Charlie. She should detect the breach and attempt + // justice, including sweeping any second-level HTLC outputs. + t.Logf("Resuming Charlie...") + restartCharlie() + + // Wait for Charlie's justice tx(s) in the mempool. There may be + // multiple: one for the commitment/HTLC outputs and potentially + // another for second-level HTLC outputs. + t.Logf("Waiting for Charlie's justice txns in mempool...") + charlieJusticeTxids, err := waitForNTxsInMempool( + net.Miner.Client, 2, wait.MinerMempoolTimeout, + ) + if err != nil { + // If we don't find 2, try with 1. + charlieJusticeTxids, err = waitForNTxsInMempool( + net.Miner.Client, 1, wait.MinerMempoolTimeout, + ) + } + require.NoError(t.t, err, + "expected Charlie's justice tx(s) in mempool") + + t.Logf("Charlie justice txids: %v", charlieJusticeTxids) + + // Log justice tx details. The BRAR may replace txs between our + // mempool query and the GetRawTransaction call, so tolerate errors. + for _, txid := range charlieJusticeTxids { + justiceTx, jErr := net.Miner.Client.GetRawTransaction(txid) + if jErr != nil { + t.Logf("Justice tx %v no longer in mempool "+ + "(likely replaced): %v", txid, jErr) + + continue + } + t.Logf("Justice tx %v has %d inputs:", + txid, len(justiceTx.MsgTx().TxIn)) + for _, txIn := range justiceTx.MsgTx().TxIn { + t.Logf(" input: %v (witness len=%d)", + txIn.PreviousOutPoint, len(txIn.Witness)) + } + for i, out := range justiceTx.MsgTx().TxOut { + t.Logf(" output %d: value=%d", i, out.Value) + } + } - // Charlie should now have a transfer for his justice transaction. - locateAssetTransfers(t.t, charlie, *charlieJusticeTxid[0]) + // Mine the justice transaction(s). We only require 1 tx in the + // mempool because the breach arbiter may consolidate multiple + // partial justice txs into a single spendAll variant at any time. + // The mined block will include all mempool txns regardless. + mineBlocks(t, net, 1, 1) + t.Logf("Justice tx confirmed") + + // After the first justice tx confirms, the breach arbiter may detect + // that some outputs were spent to second level and create follow-up + // justice txs. Give it a few blocks to react. + t.Logf("Mining blocks to let breach arbiter react to " + + "second-level...") + for i := 0; i < 10; i++ { + mempool, mErr := net.Miner.Client.GetRawMempool() + require.NoError(t.t, mErr) + + if len(mempool) > 0 { + t.Logf("Found %d txns in mempool after %d blocks:", + len(mempool), i) + for _, txid := range mempool { + raw, rErr := net.Miner.Client. + GetRawTransaction(txid) + require.NoError(t.t, rErr) + tx := raw.MsgTx() + t.Logf(" tx %v: %d inputs, %d outputs", + txid, len(tx.TxIn), len(tx.TxOut)) + for _, in := range tx.TxIn { + t.Logf(" input: %v "+ + "(witness len=%d)", + in.PreviousOutPoint, + len(in.Witness)) + } + for j, out := range tx.TxOut { + t.Logf(" output %d: value=%d", + j, out.Value) + } + } + + mineBlocks(t, net, 1, len(mempool)) + t.Logf("Mined second-level justice tx") + } else { + mineBlocks(t, net, 1, 0) + } + } - // Charlie's balance should now be the same as before the breach - // attempt: the amount he minted at the very start. - charlieBalance := ccItestAsset.Amount + // After sweeping, Charlie should have all the asset balance back. assertBalance( - t.t, charlie, charlieBalance, itest.WithAssetID(assetID), - itest.WithNumUtxos(3), + t.t, charlie, ccItestAsset.Amount, + itest.WithAssetID(assetID), ) - t.Logf("Charlie balance after breach: %d", charlieBalance) + t.Logf("Charlie balance restored after breach") } diff --git a/itest/custom_channels/helpers.go b/itest/custom_channels/helpers.go index cff40b3e9b..b8fa68c924 100644 --- a/itest/custom_channels/helpers.go +++ b/itest/custom_channels/helpers.go @@ -1257,8 +1257,10 @@ func sendAssetKeySendPayment(t *testing.T, src, dst *itest.IntegratedNode, customRecords[record.KeySendType] = preimage[:] sendReq := &routerrpc.SendPaymentRequest{ - Dest: dst.PubKey[:], - Amt: btcAmt.UnwrapOr(500), + Dest: dst.PubKey[:], + Amt: btcAmt.UnwrapOr( + int64(rfqmath.DefaultOnChainHtlcSat), + ), DestCustomRecords: customRecords, PaymentHash: hash[:], TimeoutSeconds: int32(PaymentTimeout.Seconds()), diff --git a/itest/integrated_harness.go b/itest/integrated_harness.go index c1e3d1b15a..955527faa4 100644 --- a/itest/integrated_harness.go +++ b/itest/integrated_harness.go @@ -671,6 +671,25 @@ func (h *IntegratedNetworkHarness) StopAndRestoreDB( return nil } +// SuspendNode stops the given node without cleaning it up. It returns a +// closure that can be called to restart the node. +func (h *IntegratedNetworkHarness) SuspendNode( + node *IntegratedNode) (func(), error) { + + node.Stop() + + restart := func() { + // Remove the ready file so Start() waits for the new + // instance. + if node.readyFile != "" { + _ = os.Remove(node.readyFile) + } + node.Start() + } + + return restart, nil +} + // copyAll recursively copies all files and directories from srcDir to dstDir. func copyAll(dstDir, srcDir string) error { entries, err := os.ReadDir(srcDir) diff --git a/proof/verified.go b/proof/verified.go index f62f531080..8fbc30c0fb 100644 --- a/proof/verified.go +++ b/proof/verified.go @@ -40,6 +40,20 @@ func (v verifiedAnnotatedProof) AnnotatedProof() *AnnotatedProof { // verified prevents external packages from implementing VerifiedAnnotatedProof. func (v verifiedAnnotatedProof) verified() {} +// AssumeVerifiedAnnotatedProofs wraps the given proofs as verified without +// running the proof verifier. This is used when importing already-confirmed +// channel transactions whose asset-level witnesses are placeholders (e.g. +// second-level HTLC transactions). The BTC-level on-chain confirmation serves +// as proof of validity, making VM-level witness verification unnecessary. +// +// NOTE: This should only be used for channel-related proof imports where the +// transaction is already confirmed on-chain. +func AssumeVerifiedAnnotatedProofs( + proofs ...*AnnotatedProof) []VerifiedAnnotatedProof { + + return fn.Map(proofs, newVerifiedAnnotatedProof) +} + // VerifyAnnotatedProofs verifies and enriches the given proofs with a default // verifier and returns the verified wrappers. func VerifyAnnotatedProofs(ctx context.Context, vCtx VerifierCtx, diff --git a/proof/verifier.go b/proof/verifier.go index 1e7dedb441..5af4e2afc2 100644 --- a/proof/verifier.go +++ b/proof/verifier.go @@ -89,6 +89,10 @@ type verifyOptions struct { // skipTimeLockValidationForFinalProof skips locktime checks for the // final proof in a file. skipTimeLockValidationForFinalProof bool + + // skipExclusionProofVerification skips exclusion proof checks for + // all proofs in a file. Used for breach scenario imports. + skipExclusionProofVerification bool } // defaultVerifyOptions returns a default set of proof verification options. @@ -877,6 +881,13 @@ type proofVerificationParams struct { // SkipTimeLockValidation skips locktime checks during proof // verification. SkipTimeLockValidation bool + + // SkipExclusionProofVerification skips exclusion proof checks during + // proof verification. This is used when importing confirmed + // second-level HTLC transactions in breach scenarios where we + // cannot construct exclusion proofs for the counterparty's wallet + // outputs (we don't know their internal keys). + SkipExclusionProofVerification bool } // WithChallengeBytes is a ProofVerificationOption that defines some challenge @@ -903,6 +914,24 @@ func WithSkipTimeLockValidation() ProofVerificationOption { } } +// WithSkipExclusionProofVerification skips exclusion proof verification. +// This is used for importing confirmed second-level HTLC transactions in +// breach scenarios where exclusion proofs for counterparty wallet outputs +// cannot be constructed. +func WithSkipExclusionProofVerification() ProofVerificationOption { + return func(p *proofVerificationParams) { + p.SkipExclusionProofVerification = true + } +} + +// WithSkipExclusionProofs is a file-level verify option that skips exclusion +// proof verification for all proofs in the file. +func WithSkipExclusionProofs() VerifyOption { + return func(o *verifyOptions) { + o.skipExclusionProofVerification = true + } +} + // Verify verifies the proof by ensuring that: // // 0. A proof has a valid version. @@ -1056,15 +1085,46 @@ func (p *Proof) VerifyProofIntegrity(ctx context.Context, vCtx VerifierCtx, // TODO(jhb): check for genesis asset and populate asset fields before // further verification - // The VerifyProofs method will verify the following steps: // 2. A valid inclusion proof for the resulting asset is included. - // 3. A valid inclusion proof for the split root, if the resulting asset - // is a split asset. - // 4. A set of valid exclusion proofs for the resulting asset are - // included. - tapCommitment, err := p.VerifyProofs() + tapCommitment, err := p.verifyInclusionProof() if err != nil { - return nil, fmt.Errorf("error verifying proofs: %w", err) + return nil, fmt.Errorf("invalid inclusion proof: %w", err) + } + + // 3. A valid inclusion proof for the split root, if the resulting + // asset is a split asset. + if p.Asset.HasSplitCommitmentWitness() { + if p.SplitRootProof == nil { + return nil, ErrMissingSplitRootProof + } + if err := p.verifySplitRootProof(); err != nil { + return nil, err + } + } + + // 4. A set of valid exclusion proofs for the resulting asset are + // included. For breach scenarios (second-level HTLC imports), + // exclusion proofs may be unavailable for counterparty outputs. + if !verificationParams.SkipExclusionProofVerification { + exclusionCommitVersion, err := p.verifyExclusionProofs() + if err != nil { + return nil, fmt.Errorf("invalid exclusion "+ + "proof: %w", err) + } + + if exclusionCommitVersion != nil { + if !commitment.IsSimilarTapCommitmentVersion( + &tapCommitment.Version, + exclusionCommitVersion, + ) { + + return nil, fmt.Errorf("mixed commitment "+ + "versions, inclusion %d, "+ + "exclusion %d", + tapCommitment.Version, + *exclusionCommitVersion) + } + } } // 5. If this is a genesis asset, start by verifying the @@ -1232,6 +1292,14 @@ func (f *File) Verify(ctx context.Context, } } + // Apply exclusion proof skip to all proofs if requested. + if verifyOpts.skipExclusionProofVerification { + proofOpts = append( + proofOpts, + WithSkipExclusionProofVerification(), + ) + } + result, err := decodedProof.Verify( ctx, prev, chainLookup, vCtx, proofOpts..., ) diff --git a/rfq/manager_test.go b/rfq/manager_test.go index 9fb63ac8dc..740e0cecf7 100644 --- a/rfq/manager_test.go +++ b/rfq/manager_test.go @@ -448,7 +448,7 @@ func createChannelWithCustomData(t *testing.T, id asset.ID, localBalance, ), }, nil, nil, lnwallet.CommitAuxLeaves{}, - false, + false, false, ), OpenChan: *tpchmsg.NewOpenChannel( []*tpchmsg.AssetOutput{ diff --git a/rfqmath/convert.go b/rfqmath/convert.go index 039b94bfe2..52146cebac 100644 --- a/rfqmath/convert.go +++ b/rfqmath/convert.go @@ -11,9 +11,13 @@ import ( var ( // DefaultOnChainHtlcSat is the default amount that we consider as the - // smallest HTLC amount that can be sent on-chain. This needs to be - // greater than the dust limit for an HTLC. - DefaultOnChainHtlcSat = lnwallet.DustLimitForSize( + // smallest HTLC amount that can be sent on-chain. We use 6x the dust + // limit to provide enough headroom for the baked-in second-level HTLC + // transaction fees under SigHashDefault (where the sweeper cannot add + // wallet inputs) and to comfortably clear the mempool minimum relay + // fee even when nodes compute fee rate using raw serialized size + // rather than virtual size. + DefaultOnChainHtlcSat = 6 * lnwallet.DustLimitForSize( input.UnknownWitnessSize, ) diff --git a/rfqmath/convert_test.go b/rfqmath/convert_test.go index 2188bc08ed..3d5470b06a 100644 --- a/rfqmath/convert_test.go +++ b/rfqmath/convert_test.go @@ -388,7 +388,7 @@ func TestConvertMilliSatoshiToUnits(t *testing.T) { }, expectedUnits: 1, expectedMinTransportUnits: 1, - expectedMinTransportMSat: 20_354_000, + expectedMinTransportMSat: 22_124_000, }, { // 5k USD per BTC @ decimal display 6. @@ -399,7 +399,7 @@ func TestConvertMilliSatoshiToUnits(t *testing.T) { }.ScaleTo(6), expectedUnits: 10_000, expectedMinTransportUnits: 1, - expectedMinTransportMSat: 20_354_000, + expectedMinTransportMSat: 22_124_000, }, { // 50k USD per BTC @ decimal display 6. @@ -410,7 +410,7 @@ func TestConvertMilliSatoshiToUnits(t *testing.T) { }.ScaleTo(6), expectedUnits: 1000, expectedMinTransportUnits: 1, - expectedMinTransportMSat: 2_326_308, + expectedMinTransportMSat: 4_096_308, }, { // 50M USD per BTC @ decimal display 6. @@ -420,8 +420,8 @@ func TestConvertMilliSatoshiToUnits(t *testing.T) { Scale: 2, }.ScaleTo(6), expectedUnits: 62595061158, - expectedMinTransportUnits: 179, - expectedMinTransportMSat: 355_972, + expectedMinTransportUnits: 1076, + expectedMinTransportMSat: 2_125_972, }, { // 50k USD per BTC @ decimal display 6. @@ -432,7 +432,7 @@ func TestConvertMilliSatoshiToUnits(t *testing.T) { }.ScaleTo(6), expectedUnits: 2_570, expectedMinTransportUnits: 1, - expectedMinTransportMSat: 2_326_304, + expectedMinTransportMSat: 4_096_304, }, { // 7.341M JPY per BTC @ decimal display 6. @@ -442,8 +442,8 @@ func TestConvertMilliSatoshiToUnits(t *testing.T) { Scale: 0, }.ScaleTo(6), expectedUnits: 367_092, - expectedMinTransportUnits: 25, - expectedMinTransportMSat: 367_620, + expectedMinTransportUnits: 155, + expectedMinTransportMSat: 2_137_620, }, { // 7.341M JPY per BTC @ decimal display 2. @@ -453,8 +453,8 @@ func TestConvertMilliSatoshiToUnits(t *testing.T) { Scale: 0, }.ScaleTo(4), expectedUnits: 3_670, - expectedMinTransportUnits: 25, - expectedMinTransportMSat: 367_620, + expectedMinTransportUnits: 155, + expectedMinTransportMSat: 2_137_620, }, } diff --git a/server.go b/server.go index d43f583a28..ef35de2bca 100644 --- a/server.go +++ b/server.go @@ -1,6 +1,7 @@ package taprootassets import ( + "bytes" "context" "fmt" "net/http" @@ -12,6 +13,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" proxy "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "github.com/lightninglabs/lndclient" @@ -26,6 +28,7 @@ import ( "github.com/lightninglabs/taproot-assets/tapchannel" cmsg "github.com/lightninglabs/taproot-assets/tapchannelmsg" "github.com/lightninglabs/taproot-assets/tapconfig" + "github.com/lightninglabs/taproot-assets/tapfeatures" "github.com/lightninglabs/taproot-assets/taprpc" "github.com/lightningnetwork/lnd" "github.com/lightningnetwork/lnd/build" @@ -980,8 +983,9 @@ func (s *Server) FetchLeavesFromCommit(chanState lnwl.AuxChanState, // from a channel revocation that stores balance + blob information. // // NOTE: This method is part of the lnwallet.AuxLeafStore interface. -func (s *Server) FetchLeavesFromRevocation( - r *channeldb.RevocationLog) lfn.Result[lnwl.CommitDiffAuxResult] { +func (s *Server) FetchLeavesFromRevocation(r *channeldb.RevocationLog, + chanState lnwl.AuxChanState, keys lnwl.CommitmentKeyRing, + commitTx *wire.MsgTx) lfn.Result[lnwl.CommitDiffAuxResult] { srvrLog.Debugf("FetchLeavesFromRevocation called, ourBalance=%v, "+ "teirBalance=%v, numHtlcs=%d", r.OurBalance, r.TheirBalance, @@ -989,7 +993,9 @@ func (s *Server) FetchLeavesFromRevocation( // The aux leaf creator is fully stateless, and we don't need to wait // for the server to be started before being able to use it. - return tapchannel.FetchLeavesFromRevocation(r) + return tapchannel.FetchLeavesFromRevocation( + r, chanState, keys, commitTx, s.chainParams, + ) } // ApplyHtlcView serves as the state transition function for the custom @@ -1135,6 +1141,74 @@ func (s *Server) VerifySecondLevelSigs(chanState lnwl.AuxChanState, ) } +// HtlcSigHashType returns the sighash type to use for HTLC second-level +// transactions for the given channel. The request carries either a ChanID +// (for live feature-negotiation lookups on new commitments) or a CommitBlob +// (for existing commitments), or both. +// +// When a ChanID is present and the server is configured, the live negotiated +// features are checked first. The CommitBlob is used as a fallback (or as +// the sole source when no ChanID is provided). +// +// NOTE: This method is part of the lnwallet.AuxSigner interface. +func (s *Server) HtlcSigHashType( + req lnwl.HtlcSigHashReq) lfn.Option[txscript.SigHashType] { + + // If a ChanID was provided and the server is fully configured, + // check live feature negotiation state first. + if req.ChanID.IsSome() && s != nil && s.cfg != nil && + s.cfg.AuxChanNegotiator != nil { + + chanID := req.ChanID.UnwrapOr(lnwire.ChannelID{}) + + features := s.cfg.AuxChanNegotiator.GetChannelFeatures( + chanID, + ) + + hasSigHashDefault := features.HasFeature( + tapfeatures.SigHashDefaultHTLCsOptional, + ) + + srvrLog.Debugf("HtlcSigHashType called for "+ + "chan_id=%x, "+ + "sighash_default_htlcs_negotiated=%v", + chanID[:], hasSigHashDefault) + + if hasSigHashDefault { + return lfn.Some(txscript.SigHashDefault) + } + } + + // Fall back to the commitment blob cache. + return s.htlcSigHashFromBlob(req.CommitBlob) +} + +// htlcSigHashFromBlob decodes the commitment blob and checks the cached +// SigHashDefault flag. This is used for existing commitments (breach, +// resolution) where the blob is the source of truth, and as a fallback +// during startup when the peer hasn't reconnected yet. +func (s *Server) htlcSigHashFromBlob( + commitBlob lfn.Option[tlv.Blob]) lfn.Option[txscript.SigHashType] { + + blob, err := commitBlob.UnwrapOrErr( + fmt.Errorf("no commit blob"), + ) + if err == nil { + var c cmsg.Commitment + if decErr := c.Decode(bytes.NewReader(blob)); decErr == nil { + if c.SigHashDefault.Val { + srvrLog.Debugf("HtlcSigHashType: using " + + "cached SigHashDefault from " + + "commit blob") + + return lfn.Some(txscript.SigHashDefault) + } + } + } + + return lfn.None[txscript.SigHashType]() +} + // DescFromPendingChanID takes a pending channel ID, that may already be // known due to prior custom channel messages, and maybe returns an aux // funding desc which can be used to modify how a channel is funded. @@ -1386,24 +1460,24 @@ func (s *Server) ExtraBudgetForInputs( return tapchannel.ExtraBudgetForInputs(inputs) } -// NotifyBroadcast is used to notify external callers of the broadcast of a -// sweep transaction, generated by the passed BumpRequest. +// NotifyBroadcast is called by lnd's sweeper to notify us of a sweep +// transaction broadcast, generated by the passed BumpRequest. // // NOTE: This method is part of the sweep.AuxSweeper interface. func (s *Server) NotifyBroadcast(req *sweep.BumpRequest, tx *wire.MsgTx, fee btcutil.Amount, - outpointToTxIndex map[wire.OutPoint]int) error { + outpointToTxIndex map[wire.OutPoint]int, skipBroadcast bool) error { - srvrLog.Tracef("NotifyBroadcast called, req=%v, tx=%v, fee=%v, "+ - "out_index=%v", lnutils.SpewLogClosure(req), - lnutils.SpewLogClosure(tx), fee, - lnutils.SpewLogClosure(outpointToTxIndex)) + srvrLog.Infof("NotifyBroadcast called, skip_broadcast=%v, tx=%v, "+ + "fee=%v", skipBroadcast, tx.TxHash(), fee) if err := s.waitForReady(); err != nil { return err } - return s.cfg.AuxSweeper.NotifyBroadcast(req, tx, fee, outpointToTxIndex) + return s.cfg.AuxSweeper.NotifyBroadcast( + req, tx, fee, outpointToTxIndex, skipBroadcast, + ) } // GetInitRecords is called when sending an init message to a peer. It returns diff --git a/tapchannel/auf_leaf_signer_test.go b/tapchannel/auf_leaf_signer_test.go index 05786a890c..1872664ea8 100644 --- a/tapchannel/auf_leaf_signer_test.go +++ b/tapchannel/auf_leaf_signer_test.go @@ -112,6 +112,7 @@ func setupAuxLeafSigner(t *testing.T, numJobs int32) (*AuxLeafSigner, com := cmsg.NewCommitment( nil, nil, outgoingHtlcs, nil, lnwallet.CommitAuxLeaves{}, false, + false, ) cancelChan := make(chan struct{}) diff --git a/tapchannel/aux_closer.go b/tapchannel/aux_closer.go index d41f58edc0..9285604e90 100644 --- a/tapchannel/aux_closer.go +++ b/tapchannel/aux_closer.go @@ -650,7 +650,8 @@ func (a *AuxChanCloser) ShutdownBlob( func shipChannelTxn(txSender tapfreighter.Porter, chanTx *wire.MsgTx, outputCommitments tappsbt.OutputCommitments, vPkts []*tappsbt.VPacket, closeFee int64, - anchorTxHeightHint fn.Option[uint32]) error { + anchorTxHeightHint fn.Option[uint32], skipBroadcast bool, + parcelOpts ...tapfreighter.PreAnchoredParcelOpt) error { chanTxPsbt, err := tapsend.PrepareAnchoringTemplate(vPkts) if err != nil { @@ -679,8 +680,8 @@ func shipChannelTxn(txSender tapfreighter.Porter, chanTx *wire.MsgTx, } parcelLabel := fmt.Sprintf("channel-tx-%s", chanTx.TxHash().String()) preSignedParcel := tapfreighter.NewPreAnchoredParcel( - vPkts, nil, closeAnchor, false, parcelLabel, - anchorTxHeightHint, + vPkts, nil, closeAnchor, skipBroadcast, parcelLabel, + anchorTxHeightHint, parcelOpts..., ) _, err = txSender.RequestShipment(preSignedParcel) if err != nil { @@ -795,5 +796,6 @@ func (a *AuxChanCloser) FinalizeClose(desc types.AuxCloseDesc, return shipChannelTxn( a.cfg.TxSender, closeTx, closeInfo.outputCommitments, closeInfo.vPackets, closeInfo.closeFee, fn.None[uint32](), + false, ) } diff --git a/tapchannel/aux_funding_controller.go b/tapchannel/aux_funding_controller.go index 6ec7ca6fa1..1f92a576ff 100644 --- a/tapchannel/aux_funding_controller.go +++ b/tapchannel/aux_funding_controller.go @@ -432,6 +432,8 @@ type pendingAssetFunding struct { stxo bool + sigHashDefault bool + amt uint64 pushAmt btcutil.Amount @@ -556,7 +558,7 @@ func newCommitBlobAndLeaves(pendingFunding *pendingAssetFunding, lndOpenChan lnwallet.AuxChanState, assetOpenChan *cmsg.OpenChannel, keyRing lntypes.Dual[lnwallet.CommitmentKeyRing], whoseCommit lntypes.ChannelParty, - stxo bool) ([]byte, lnwallet.CommitAuxLeaves, + stxo, sigHashDefault bool) ([]byte, lnwallet.CommitAuxLeaves, error) { chanAssets := assetOpenChan.FundedAssets.Val.Outputs @@ -591,7 +593,7 @@ func newCommitBlobAndLeaves(pendingFunding *pendingAssetFunding, // needs the sum of the remote+local assets, so we'll populate that. fakePrevState := cmsg.NewCommitment( localAssets, remoteAssets, nil, nil, lnwallet.CommitAuxLeaves{}, - stxo, + stxo, false, ) // Just like above, we don't have a real HTLC view here, so we'll pass @@ -604,7 +606,7 @@ func newCommitBlobAndLeaves(pendingFunding *pendingAssetFunding, fakePrevState, lndOpenChan, assetOpenChan, whoseCommit, localSatBalance, remoteSatBalance, fakeView, pendingFunding.chainParams, keyRing.GetForParty(whoseCommit), - stxo, + stxo, sigHashDefault, ) if err != nil { return nil, lnwallet.CommitAuxLeaves{}, err @@ -647,14 +649,14 @@ func (p *pendingAssetFunding) toAuxFundingDesc(req *bindFundingReq, // This will be the information for the very first state (state 0). localCommitBlob, localAuxLeaves, err := newCommitBlobAndLeaves( p, req.openChan, openChanDesc, req.keyRing, lntypes.Local, - p.stxo, + p.stxo, p.sigHashDefault, ) if err != nil { return nil, err } remoteCommitBlob, remoteAuxLeaves, err := newCommitBlobAndLeaves( p, req.openChan, openChanDesc, req.keyRing, lntypes.Remote, - p.stxo, + p.stxo, p.sigHashDefault, ) if err != nil { return nil, err @@ -1806,6 +1808,9 @@ func (f *FundingController) processFundingReq(fundingFlows fundingFlowIndex, supportSTXO := features.HasFeature(tapfeatures.STXOOptional) fundingState.stxo = supportSTXO + fundingState.sigHashDefault = features.HasFeature( + tapfeatures.SigHashDefaultHTLCsOptional, + ) // Now that we know the final funding asset root along with the splits, // we can derive the tapscript root that'll be used alongside the diff --git a/tapchannel/aux_leaf_creator.go b/tapchannel/aux_leaf_creator.go index bbf0737ea1..b0e1688e3c 100644 --- a/tapchannel/aux_leaf_creator.go +++ b/tapchannel/aux_leaf_creator.go @@ -68,11 +68,14 @@ func FetchLeavesFromView(chainParams *address.ChainParams, ) supportsSTXO := features.HasFeature(tapfeatures.STXOOptional) + sigHashDefault := features.HasFeature( + tapfeatures.SigHashDefaultHTLCsOptional, + ) allocations, newCommitment, err := GenerateCommitmentAllocations( prevState, in.ChannelState, chanAssetState, in.WhoseCommit, in.OurBalance, in.TheirBalance, in.UnfilteredView, chainParams, - in.KeyRing, supportsSTXO, + in.KeyRing, supportsSTXO, sigHashDefault, ) if err != nil { return lfn.Err[returnType](fmt.Errorf("unable to generate "+ @@ -221,8 +224,13 @@ func FetchLeavesFromCommit(chainParams *address.ChainParams, // FetchLeavesFromRevocation attempts to fetch the auxiliary leaves // from a channel revocation that stores balance + blob information. -func FetchLeavesFromRevocation( - r *channeldb.RevocationLog) lfn.Result[lnwl.CommitDiffAuxResult] { +// The additional parameters (chanState, keys, commitTx, chainParams) +// are needed to compute second-level HTLC auxiliary leaves at runtime, +// since these are not stored in the commitment blob. +func FetchLeavesFromRevocation(r *channeldb.RevocationLog, + chanState lnwl.AuxChanState, keys lnwl.CommitmentKeyRing, + commitTx *wire.MsgTx, + chainParams *address.ChainParams) lfn.Result[lnwl.CommitDiffAuxResult] { type returnType = lnwl.CommitDiffAuxResult @@ -235,13 +243,136 @@ func FetchLeavesFromRevocation( "to decode commitment: %w", err)) } + leaves := commitment.Leaves() + + // If we have the commit tx and chain params, we + // can compute the second-level HTLC aux leaves + // that aren't stored in the commitment blob. + if commitTx != nil && chainParams != nil { + err = populateSecondLevelLeaves( + r, commitment, chanState, keys, + commitTx, chainParams, &leaves, + ) + if err != nil { + return lfn.Err[returnType]( + fmt.Errorf("unable to "+ + "populate second "+ + "level leaves: %w", + err), + ) + } + } + return lfn.Ok(lnwl.CommitDiffAuxResult{ - AuxLeaves: lfn.Some(commitment.Leaves()), + AuxLeaves: lfn.Some(leaves), }) }, ) } +// populateSecondLevelLeaves computes the second-level HTLC aux leaves +// for each HTLC in the revocation log and populates them in the given +// leaves struct. This mirrors the logic in FetchLeavesFromCommit. +func populateSecondLevelLeaves(r *channeldb.RevocationLog, + commitment *cmsg.Commitment, chanState lnwl.AuxChanState, + keys lnwl.CommitmentKeyRing, commitTx *wire.MsgTx, + chainParams *address.ChainParams, + leaves *lnwl.CommitAuxLeaves) error { + + supportSTXO := commitment.STXO.Val + + incomingHtlcs := commitment.IncomingHtlcAssets.Val.HtlcOutputs + incomingHtlcLeaves := commitment.AuxLeaves.Val. + IncomingHtlcLeaves.Val.HtlcAuxLeaves + outgoingHtlcs := commitment.OutgoingHtlcAssets.Val.HtlcOutputs + outgoingHtlcLeaves := commitment.AuxLeaves.Val. + OutgoingHtlcLeaves.Val.HtlcAuxLeaves + + for _, htlcEntry := range r.HTLCEntries { + // Skip HTLCs without an index. + htlcIdxOpt := htlcEntry.HtlcIndex.ValOpt() + if htlcIdxOpt.IsNone() { + continue + } + + htlcIdx := htlcIdxOpt.UnsafeFromSome().Int() + htlcAmt := htlcEntry.Amt.Val.Int() + + if htlcEntry.Incoming.Val { + htlcOutputs := incomingHtlcs[htlcIdx].Outputs + auxLeaf := incomingHtlcLeaves[htlcIdx].AuxLeaf + + if len(htlcOutputs) == 0 { + continue + } + + // For incoming HTLCs on the remote party's + // commitment, they'll need to go to the second + // level to time it out. + cltvTimeout := fn.Some( + htlcEntry.RefundTimeout.Val, + ) + + leaf, err := CreateSecondLevelHtlcTx( + chanState, commitTx, htlcAmt, + keys, chainParams, htlcOutputs, + cltvTimeout, htlcIdx, supportSTXO, + ) + if err != nil { + return fmt.Errorf("unable to create "+ + "second level incoming HTLC "+ + "leaf: %w", err) + } + + existingLeaf := lfn.MapOption( + func(l cmsg.TapLeafRecord) txscript.TapLeaf { + return l.Leaf + }, + )(auxLeaf.ValOpt()) + + leaves.IncomingHtlcLeaves[htlcIdx] = input.HtlcAuxLeaf{ + AuxTapLeaf: existingLeaf, + SecondLevelLeaf: leaf, + } + } else { + htlcOutputs := outgoingHtlcs[htlcIdx].Outputs + auxLeaf := outgoingHtlcLeaves[htlcIdx].AuxLeaf + + if len(htlcOutputs) == 0 { + continue + } + + // For outgoing HTLCs on the remote party's + // commitment, they don't need a CLTV timeout + // (they go to second level via the success path). + leaf, err := CreateSecondLevelHtlcTx( + chanState, commitTx, htlcAmt, + keys, chainParams, htlcOutputs, + fn.None[uint32](), htlcIdx, + supportSTXO, + ) + if err != nil { + return fmt.Errorf("unable to create "+ + "second level outgoing HTLC "+ + "leaf: %w", err) + } + + existingLeaf := lfn.MapOption( + func(l cmsg.TapLeafRecord) txscript.TapLeaf { + return l.Leaf + }, + )(auxLeaf.ValOpt()) + + leaves.OutgoingHtlcLeaves[htlcIdx] = input.HtlcAuxLeaf{ + AuxTapLeaf: existingLeaf, + SecondLevelLeaf: leaf, + } + } + } + + return nil +} + // ApplyHtlcView serves as the state transition function for the custom // channel's blob. Given the old blob, and an HTLC view, then a new // blob should be returned that reflects the pending updates. @@ -279,11 +410,14 @@ func ApplyHtlcView(chainParams *address.ChainParams, supportSTXO := features.HasFeature( tapfeatures.STXOOptional, ) + sigHashDefault := features.HasFeature( + tapfeatures.SigHashDefaultHTLCsOptional, + ) _, newCommitment, err := GenerateCommitmentAllocations( prevState, in.ChannelState, chanAssetState, in.WhoseCommit, in.OurBalance, in.TheirBalance, in.UnfilteredView, chainParams, - in.KeyRing, supportSTXO, + in.KeyRing, supportSTXO, sigHashDefault, ) if err != nil { return lfn.Err[returnType](fmt.Errorf("unable to generate "+ diff --git a/tapchannel/aux_leaf_signer.go b/tapchannel/aux_leaf_signer.go index 4ad34bea50..e134fbc3b8 100644 --- a/tapchannel/aux_leaf_signer.go +++ b/tapchannel/aux_leaf_signer.go @@ -438,52 +438,67 @@ func verifyHtlcSignature(chainParams *address.ChainParams, // applySignDescToVIn applies the sign descriptor to the virtual input. This // entails updating all the input bip32, taproot, and witness fields with the // information from the sign descriptor. This function returns the public key -// that should be used to verify the generated signature, and also the leaf to -// be signed. +// that should be used to verify the generated signature. For scriptspend, it +// also returns the leaf to be signed. For breach scenarios (keyspend), the +// leaf will be empty. func applySignDescToVIn(signDesc input.SignDescriptor, vIn *tappsbt.VInput, chainParams *address.ChainParams, tapscriptRoot []byte) (btcec.PublicKey, txscript.TapLeaf) { - leafToSign := txscript.TapLeaf{ - Script: signDesc.WitnessScript, - LeafVersion: txscript.BaseLeafVersion, - } - vIn.TaprootLeafScript = []*psbt.TaprootTapLeafScript{ - { - Script: leafToSign.Script, - LeafVersion: leafToSign.LeafVersion, - }, - } + var leafToSign txscript.TapLeaf + // Detect breach scenario by checking if both SingleTweak and + // DoubleTweak are present. In breach scenarios (HTLC revocations), both + // tweaks are needed: DoubleTweak for the revocation key and SingleTweak + // for the HTLC index. In normal force close scenarios, only one tweak + // is present at a time. + isBreach := len(signDesc.SingleTweak) > 0 && + signDesc.DoubleTweak != nil + + // Set up derivation paths for the key. deriv, trDeriv := tappsbt.Bip32DerivationFromKeyDesc( signDesc.KeyDesc, chainParams.HDCoinType, ) vIn.Bip32Derivation = []*psbt.Bip32Derivation{deriv} - vIn.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{ - trDeriv, - } - vIn.TaprootBip32Derivation[0].LeafHashes = [][]byte{ - fn.ByteSlice(leafToSign.TapHash()), + vIn.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{trDeriv} + + if !isBreach { + // For normal sweeps (scriptspend), set up the leaf script. + leafToSign = txscript.TapLeaf{ + Script: signDesc.WitnessScript, + LeafVersion: txscript.BaseLeafVersion, + } + vIn.TaprootLeafScript = []*psbt.TaprootTapLeafScript{ + { + Script: leafToSign.Script, + LeafVersion: leafToSign.LeafVersion, + }, + } + vIn.TaprootBip32Derivation[0].LeafHashes = [][]byte{ + fn.ByteSlice(leafToSign.TapHash()), + } } + vIn.SighashType = signDesc.HashType vIn.TaprootMerkleRoot = tapscriptRoot - // Apply single or double tweaks if present in the sign - // descriptor. At the same time, we apply the tweaks to a copy - // of the public key, so we can validate the produced signature. + // Apply single or double tweaks if present in the sign descriptor. At + // the same time, we apply the tweaks to a copy of the public key, so we + // can validate the produced signature. + // + // For breach scenarios, both DoubleTweak and SingleTweak are present. + // Both are added to the PSBT unknowns keyed by their type, so the + // append order here doesn't matter — the signer identifies them by + // key type, not position. However, when deriving the verification + // public key below, we must apply DoubleTweak (revocation) before + // SingleTweak (HTLC index) because DeriveRevocationPubkey hashes + // its input key, making the operations non-commutative. + // + // For normal force closes, only one tweak is present at a time. signingKey := signDesc.KeyDesc.PubKey - if len(signDesc.SingleTweak) > 0 { - key := btcwallet.PsbtKeyTypeInputSignatureTweakSingle - vIn.Unknowns = append(vIn.Unknowns, &psbt.Unknown{ - Key: key, - Value: signDesc.SingleTweak, - }) - signingKey = input.TweakPubKeyWithTweak( - signingKey, signDesc.SingleTweak, - ) - } - if signDesc.DoubleTweak != nil { + if isBreach { + // Breach scenario: set both tweaks in PSBT unknowns. key := btcwallet.PsbtKeyTypeInputSignatureTweakDouble vIn.Unknowns = append(vIn.Unknowns, &psbt.Unknown{ Key: key, @@ -493,6 +508,41 @@ func applySignDescToVIn(signDesc input.SignDescriptor, vIn *tappsbt.VInput, signingKey = input.DeriveRevocationPubkey( signingKey, signDesc.DoubleTweak.PubKey(), ) + + key = btcwallet.PsbtKeyTypeInputSignatureTweakSingle + vIn.Unknowns = append(vIn.Unknowns, &psbt.Unknown{ + Key: key, + Value: signDesc.SingleTweak, + }) + + signingKey = input.TweakPubKeyWithTweak( + signingKey, signDesc.SingleTweak, + ) + } else { + // Normal force close: Apply tweaks in the original order. + // Apply SingleTweak first (if present), then DoubleTweak. + if len(signDesc.SingleTweak) > 0 { + key := btcwallet.PsbtKeyTypeInputSignatureTweakSingle + vIn.Unknowns = append(vIn.Unknowns, &psbt.Unknown{ + Key: key, + Value: signDesc.SingleTweak, + }) + + signingKey = input.TweakPubKeyWithTweak( + signingKey, signDesc.SingleTweak, + ) + } + if signDesc.DoubleTweak != nil { + key := btcwallet.PsbtKeyTypeInputSignatureTweakDouble + vIn.Unknowns = append(vIn.Unknowns, &psbt.Unknown{ + Key: key, + Value: signDesc.DoubleTweak.Serialize(), + }) + + signingKey = input.DeriveRevocationPubkey( + signingKey, signDesc.DoubleTweak.PubKey(), + ) + } } return *signingKey, leafToSign diff --git a/tapchannel/aux_sweeper.go b/tapchannel/aux_sweeper.go index 5af5255b8f..a5b749586b 100644 --- a/tapchannel/aux_sweeper.go +++ b/tapchannel/aux_sweeper.go @@ -26,6 +26,7 @@ import ( "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/lightningnetwork/lnd/channeldb" lfn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" @@ -88,6 +89,12 @@ type broadcastReq struct { // sure we make proofs properly for the pre-signed HTLC transactions. outpointToTxIndex map[wire.OutPoint]int + // skipBroadcast indicates whether the porter should skip broadcasting + // the transaction. This is set to true when the transaction is already + // confirmed on-chain (e.g., breach sweeps, force close commitments), + // and false for normal sweeps that need to be broadcast. + skipBroadcast bool + // resp is the error result of the broadcast. resp chan error } @@ -351,20 +358,70 @@ func (a *AuxSweeper) signSweepVpackets(vPackets []*tappsbt.VPacket, // single asset from our commitment output. vIn := vPacket.Inputs[0] - // Next, we'll apply the sign desc to the vIn, setting the PSBT - // specific fields. Along the way, we'll apply any relevant - // tweaks to generate the key we'll use to verify the - // signature. - signingKey, leafToSign := applySignDescToVIn( - signDesc, vIn, &a.cfg.ChainParams, tapTweak, + // Check if this is a breach scenario (HTLC revocation). + // Breach scenarios have no control block (keyspend), + // while normal force close sweeps use scriptspend + // (control block present). + isBreach := len(ctrlBlock) == 0 + + var ( + signingKey btcec.PublicKey + leafToSign txscript.TapLeaf + signMethod input.SignMethod + tapLeafOpt lfn.Option[txscript.TapLeaf] ) - // In this case, the witness isn't special, so we'll set the - // control block now for it. - vIn.TaprootLeafScript[0].ControlBlock = ctrlBlock + if isBreach { + // For breach scenarios (HTLC revocations), the + // common function applies both DoubleTweak + // (revocation) and SingleTweak (HTLC index) to + // derive the private key for signing. We discard + // the returned signingKey because for breach + // verification we use the asset's script key + // instead (see below). + _, leafToSign = applySignDescToVIn( + signDesc, vIn, &a.cfg.ChainParams, tapTweak, + ) + + // For keyspend, we need to verify the signature against + // the asset's script key, not the derived signing key. + // The asset script key was set during commitment + // creation and incorporates both the revocation key + // derivation and the HTLC index tweak via + // TweakHtlcTree(), which also recomputes the taproot + // output key with the script root. + inputAsset := vIn.Asset() + signingKey = *inputAsset.ScriptKey.PubKey + signMethod = input.TaprootKeySpendSignMethod + + tapLeafOpt = lfn.None[txscript.TapLeaf]() + } else { + // For normal force close sweeps, we use scriptspend. + signingKey, leafToSign = applySignDescToVIn( + signDesc, vIn, &a.cfg.ChainParams, tapTweak, + ) + + // In this case, the witness isn't special, so we'll set + // the control block now for it. + vIn.TaprootLeafScript[0].ControlBlock = ctrlBlock + + signMethod = input.TaprootScriptSpendSignMethod + + tapLeafOpt = lfn.Some(leafToSign) + } - log.Debugf("signing vPacket for input=%v", - limitSpewer.Sdump(vIn.PrevID)) + log.Infof("signing vPacket[%d]: isBreach=%v, "+ + "signMethod=%v, signingKey=%x, "+ + "inputScriptKey=%x, tapTweak=%x, "+ + "singleTweak=%x, doubleTweak=%v", + vPktIndex, isBreach, + signMethod, + signingKey.SerializeCompressed(), + vIn.Asset().ScriptKey.PubKey. + SerializeCompressed(), + tapTweak, + signDesc.SingleTweak, + signDesc.DoubleTweak != nil) // With everything set, we can now sign the new leaf we'll // sweep into. @@ -373,8 +430,8 @@ func (a *AuxSweeper) signSweepVpackets(vPackets []*tappsbt.VPacket, ctxb, vPacket, tapfreighter.SkipInputProofVerify(), tapfreighter.WithValidator(&schnorrSigValidator{ pubKey: signingKey, - tapLeaf: lfn.Some(leafToSign), - signMethod: input.TaprootScriptSpendSignMethod, + tapLeaf: tapLeafOpt, + signMethod: signMethod, }), ) if err != nil { @@ -551,6 +608,32 @@ func (a *AuxSweeper) createAndSignSweepVpackets( }, )(desc.auxSigInfo).UnwrapOr(resReq.SignDesc) + // For HTLC revocation sweeps (breach scenarios), we need to + // apply the HTLC index tweak to the SignDesc. This is indicated + // by an empty control block (keyspend path). The HTLC index is + // passed in resReq.HtlcID. This tweak is applied at the ASSET + // level only (not Bitcoin level). + // + // IMPORTANT: Only apply this for breach scenarios, NOT for + // normal force close HTLC sweeps. + isBreach := len(desc.ctrlBlockBytes) == 0 + if isBreach { + // For breach scenarios, the HTLC ID must be present + // to compute the single tweak. + htlcID, err := resReq.HtlcID.UnwrapOrErr(errNoHtlcID) + if err != nil { + return lfn.Err[returnType](err) + } + + // Derive the single tweak from the HTLC index, + // using the same function used during commitment + // creation to ensure consistency. + tweakScalar := ScriptKeyTweakFromHtlcIndex(htlcID) + var singleTweak [32]byte + tweakScalar.PutBytesUnchecked(singleTweak[:]) + signDesc.SingleTweak = singleTweak[:] + } + err := a.signSweepVpackets( vPkts, signDesc, desc.scriptTree.TapTweak(), desc.ctrlBlockBytes, desc.auxSigInfo, @@ -1015,6 +1098,171 @@ func localHtlcSuccessSweepDesc(req lnwallet.ResolutionReq, }) } +// tweakHtlcScriptTree applies the HTLC index tweak to the script tree's +// internal key, returning a new HtlcScriptTree with the tweaked keys but +// the original leaves and tapscript structure preserved. +func tweakHtlcScriptTree(tree *input.HtlcScriptTree, + index input.HtlcIndex) *input.HtlcScriptTree { + + tweakedTree := TweakHtlcTree(tree.ScriptTree, index) + + return &input.HtlcScriptTree{ + ScriptTree: input.ScriptTree{ + InternalKey: tweakedTree.InternalKey, + TaprootKey: tweakedTree.TaprootKey, + TapscriptTree: tree.TapscriptTree, + TapscriptRoot: tree.TapscriptRoot, + }, + SuccessTapLeaf: tree.SuccessTapLeaf, + TimeoutTapLeaf: tree.TimeoutTapLeaf, + AuxLeaf: tree.AuxLeaf, + } +} + +// htlcOfferedRevokeSweepDesc creates a sweep descriptor for a revoked HTLC +// where htlc.Incoming=false in the remote's commitment log (meaning we're +// sending to them). We use the revocation key to keyspend immediately. +// +// IMPORTANT: Like all other HTLC sweep descriptors, we must use a TWEAKED +// keyring where the RevocationKey has the HTLC index tweak applied. This +// matches how the HTLC was created during commitment generation. +func htlcOfferedRevokeSweepDesc(originalKeyRing *lnwallet.CommitmentKeyRing, + payHash []byte, htlcExpiry uint32, + index input.HtlcIndex) lfn.Result[tapscriptSweepDescs] { + + type returnType = tapscriptSweepDescs + + // IMPORTANT: We must match the creation flow exactly: + // 1. Create script tree with UNTWEAKED keyring + // 2. Then apply HTLC index tweak to the tree's internal key + htlcScriptTree, err := input.ReceiverHTLCScriptTaproot( + htlcExpiry, originalKeyRing.LocalHtlcKey, + originalKeyRing.RemoteHtlcKey, originalKeyRing.RevocationKey, + payHash, lntypes.Remote, input.NoneTapLeaf(), + ) + if err != nil { + return lfn.Err[returnType](err) + } + + // Apply the HTLC index tweak to the tree, matching how HTLCs are + // created in commitment.go. + tweakedHtlcTree := tweakHtlcScriptTree(htlcScriptTree, index) + + // For revoked HTLCs, we use keyspend (not scriptspend), so we don't + // need a control block. The revocation key spend path allows immediate + // sweep without CSV delays. + return lfn.Ok(tapscriptSweepDescs{ + firstLevel: tapscriptSweepDesc{ + scriptTree: tweakedHtlcTree, + }, + }) +} + +// htlcAcceptedRevokeSweepDesc creates a sweep descriptor for a revoked HTLC +// that was accepted by the remote party (incoming from their perspective). We +// use the revocation key to keyspend immediately. +// +// IMPORTANT: Like all other HTLC sweep descriptors, we must use a TWEAKED +// keyring where the RevocationKey has the HTLC index tweak applied. This +// matches how the HTLC was created during commitment generation. +func htlcAcceptedRevokeSweepDesc(originalKeyRing *lnwallet.CommitmentKeyRing, + payHash []byte, index input.HtlcIndex) lfn.Result[tapscriptSweepDescs] { + + type returnType = tapscriptSweepDescs + + // IMPORTANT: We must match the creation flow exactly: + // 1. Create script tree with UNTWEAKED keyring + // 2. Then apply HTLC index tweak to the tree's internal key + // + // During creation, GenTaprootHtlcScript is called with the untweaked + // keyring, then TweakHtlcTree applies the index tweak. We must do + // the same here. + // + // For TaprootHtlcAcceptedRevoke (htlc.Incoming=true in remote's log), + // this means incoming to us (they're sending to us). + // On remote's commitment with them sending, GenTaprootHtlcScript uses: + // isIncoming && whoseCommit.IsRemote() → SenderHTLCScriptTaproot + // with parameters: RemoteHtlcKey, LocalHtlcKey (in that order!) + // where RemoteHtlcKey = sender (them), LocalHtlcKey = receiver (us) + htlcScriptTree, err := input.SenderHTLCScriptTaproot( + originalKeyRing.RemoteHtlcKey, originalKeyRing.LocalHtlcKey, + originalKeyRing.RevocationKey, payHash, lntypes.Remote, + input.NoneTapLeaf(), + ) + if err != nil { + return lfn.Err[returnType](err) + } + + // Apply the HTLC index tweak to the tree, matching how HTLCs are + // created in commitment.go. + tweakedHtlcTree := tweakHtlcScriptTree(htlcScriptTree, index) + + // For revoked HTLCs, we use keyspend (not scriptspend), so we don't + // need a control block. The revocation key spend path allows immediate + // sweep without CSV delays. + return lfn.Ok(tapscriptSweepDescs{ + firstLevel: tapscriptSweepDesc{ + scriptTree: tweakedHtlcTree, + }, + }) +} + +// htlcSecondLevelRevokeSweepDesc creates a sweep descriptor for a revoked +// second-level HTLC transaction. The revocation key is the internal key of +// the second-level script tree, so we sweep via keyspend (no control block +// needed), matching the LND-side TaprootHtlcSpendRevoke witness generation. +// +// IMPORTANT: Like all other HTLC sweep descriptors, we must use a TWEAKED +// keyring where the RevocationKey has the HTLC index tweak applied. This +// matches how the HTLC was created during commitment generation. +func htlcSecondLevelRevokeSweepDesc( + originalKeyRing *lnwallet.CommitmentKeyRing, csvDelay uint32, + index input.HtlcIndex, + auxLeaf input.AuxTapLeaf) lfn.Result[tapscriptSweepDescs] { + + type returnType = tapscriptSweepDescs + + // IMPORTANT: We must match the creation flow exactly: + // 1. Create script tree with UNTWEAKED keyring and NO aux leaf + // 2. Then apply HTLC index tweak to the tree's internal key + // + // The aux leaf is intentionally omitted here. During commitment + // generation, the ASSET-level script key is derived from the tree + // WITHOUT the aux leaf (createSecondLevelHtlcAllocations passes + // None). The aux leaf only affects the BTC-level on-chain output, + // not the asset-level script key derivation. + _ = auxLeaf // Used at BTC level, not needed for asset signing. + secondLevelScriptTree, err := input.TaprootSecondLevelScriptTree( + originalKeyRing.RevocationKey, originalKeyRing.ToLocalKey, + csvDelay, lfn.None[txscript.TapLeaf](), + ) + if err != nil { + return lfn.Err[returnType](err) + } + + // Now apply the HTLC index tweak to the tree, matching how HTLCs + // are created in commitment.go. + tweakedTree := TweakHtlcTree(secondLevelScriptTree.ScriptTree, index) + + // Create a new SecondLevelScriptTree with the tweaked keys. + tweakedScriptTree := &input.SecondLevelScriptTree{ + ScriptTree: input.ScriptTree{ + InternalKey: tweakedTree.InternalKey, + TaprootKey: tweakedTree.TaprootKey, + TapscriptTree: secondLevelScriptTree.TapscriptTree, + TapscriptRoot: secondLevelScriptTree.TapscriptRoot, + }, + } + + // For second-level revocations, we use keyspend (the internal key + // is the revocation key), so no control block is needed. + return lfn.Ok(tapscriptSweepDescs{ + firstLevel: tapscriptSweepDesc{ + scriptTree: tweakedScriptTree, + }, + }) +} + // assetOutputToVPacket converts an asset outputs to the corresponding vPackets // that can be used to complete the proof needed to import a commitment // transaction. This new vPacket is added to the specified map. @@ -1729,15 +1977,152 @@ func (a *AuxSweeper) importCommitTx(req lnwallet.ResolutionReq, heightHint = fn.Some(req.CommitTxBlockHeight) } + // We set the skipBroadcast flag because this is called after a force + // close - the commitment transaction is already confirmed on-chain. return shipChannelTxn( a.cfg.TxSender, req.CommitTx, outCommitments, vPackets, - int64(req.CommitFee), heightHint, + int64(req.CommitFee), heightHint, true, + ) +} + +// importSecondLevelHtlcTx is a placeholder for future full proof import +// of the second-level HTLC transition. Currently, the proof chain is +// handled by importing the second-level tx's proof into the archive. +// With SIGHASH_DEFAULT for custom channels, the second-level tx is +// deterministic (1 input, 1 output), so we can construct the full proof +// including exclusion proofs. +func (a *AuxSweeper) importSecondLevelHtlcTx( + req lnwallet.ResolutionReq, + secondLevelPkts []*tappsbt.VPacket, + secondLevelAllocs []*tapsend.Allocation, + commitState *cmsg.Commitment) error { + + secondLevelTx := req.SecondLevelTx + if secondLevelTx == nil { + return fmt.Errorf("no second-level tx provided") + } + + ctx := context.Background() + secondLevelTxHash := secondLevelTx.TxHash() + + // Check if already imported. + existingParcels, err := a.cfg.TxSender.QueryParcels( + ctx, fn.Some(secondLevelTxHash), false, + ) + if err != nil { + return fmt.Errorf("querying second-level parcels: %w", err) + } + if len(existingParcels) > 0 { + log.Infof("Second-level tx %v already imported", + secondLevelTxHash) + return nil + } + + log.Infof("Importing second-level HTLC tx %v (height=%d)", + secondLevelTxHash, req.SecondLevelTxBlockHeight) + + supportSTXO := commitState.STXO.Val + + // Prepare output assets. + for _, vPkt := range secondLevelPkts { + if err := tapsend.PrepareOutputAssets( + ctx, vPkt, + ); err != nil { + return fmt.Errorf("unable to prepare output "+ + "assets: %w", err) + } + + // Set a minimal witness. The tx is confirmed, so the + // witness just needs to be non-empty. + for _, vOut := range vPkt.Outputs { + if vOut.Asset == nil { + continue + } + + err := vOut.Asset.UpdateTxWitness( + 0, wire.TxWitness{{0x01}}, + ) + if err != nil { + return fmt.Errorf("updating witness: %w", + err) + } + } + } + + var ( + opts []tapsend.OutputCommitmentOption + proofOpts []proof.GenOption + ) + if !supportSTXO { + opts = append(opts, tapsend.WithNoSTXOProofs()) + proofOpts = append(proofOpts, proof.WithNoSTXOProofs()) + } + + outCommitments, err := tapsend.CreateOutputCommitments( + secondLevelPkts, opts..., + ) + if err != nil { + return fmt.Errorf("unable to create output "+ + "commitments: %w", err) + } + + err = tapsend.AssignOutputCommitments( + secondLevelAllocs, outCommitments, + ) + if err != nil { + return fmt.Errorf("unable to assign output "+ + "commitments: %w", err) + } + + // Create proof suffixes. With SIGHASH_DEFAULT, the second-level + // tx is deterministic (1 output), so our allocations cover all + // outputs and exclusion proofs can be properly created. + exclusionCreator := tapsend.NonAssetExclusionProofs( + secondLevelAllocs, + ) + for idx := range secondLevelPkts { + vPkt := secondLevelPkts[idx] + for outIdx := range vPkt.Outputs { + proofSuffix, err := tapsend.CreateProofSuffixCustom( + secondLevelTx, vPkt, outCommitments, + outIdx, secondLevelPkts, + exclusionCreator, proofOpts..., + ) + if err != nil { + return fmt.Errorf("unable to create proof "+ + "suffix for output %d: %w", + outIdx, err) + } + + vPkt.Outputs[outIdx].ProofSuffix = proofSuffix + } + } + + // Ship the second-level tx. It's already confirmed. + heightHint := fn.None[uint32]() + if req.SecondLevelTxBlockHeight > 0 { + heightHint = fn.Some(req.SecondLevelTxBlockHeight) + } + + // Skip proof verification for the second-level import. The + // asset-level witnesses are placeholders because we don't possess + // the HTLC script keys needed for a valid asset witness. The + // BTC-level transaction is already confirmed on-chain, which + // serves as proof of validity. + return shipChannelTxn( + a.cfg.TxSender, secondLevelTx, outCommitments, + secondLevelPkts, 0, heightHint, true, + tapfreighter.WithSkipProofVerify(), ) } // errNoPayHash is an error returned when no payment hash is provided. var errNoPayHash = fmt.Errorf("no payment hash provided") +// errNoHtlcID is an error returned when no HTLC ID is provided for a keyspend +// scenario that requires it. +var errNoHtlcID = fmt.Errorf("no HTLC ID provided for keyspend") + // resolveContract takes in a resolution request and resolves it by creating a // serialized resolution blob that contains the virtual packets needed to sweep // the funds from the contract. @@ -1890,14 +2275,271 @@ func (a *AuxSweeper) resolveContract( needsSecondLevel = true - default: - // TODO(guggero): Need to do HTLC revocation cases here. - // IMPORTANT: Remember that we applied the HTLC index as a tweak - // to the revocation key on the asset level! That means the - // tweak to the first-level HTLC script key's internal key - // (which is the revocation key) MUST be applied when creating - // a breach sweep transaction! + // Revoked HTLC offered by remote party (outgoing HTLC from their side). + // We sweep this using the revocation key (keyspend). + case input.TaprootHtlcOfferedRevoke: + // Filter for the specific HTLC we're sweeping. The remote party + // offered this HTLC to us (sending to us), so from + // their PoV it's outgoing and stored in their + // OutgoingHtlcAssets. We sweep it using the revocation + // key since they broadcast a revoked state. + htlcID := req.HtlcID.UnwrapOr(math.MaxUint64) + htlcOutputs := commitState.OutgoingHtlcAssets.Val + assetOutputs = htlcOutputs.FilterByHtlcIndex(htlcID) + + payHash, err := req.PayHash.UnwrapOrErr(errNoPayHash) + if err != nil { + return lfn.Err[tlv.Blob](err) + } + + htlcExpiry := req.CltvDelay.UnwrapOr(0) + + // Create sweep descriptor for revoked offered HTLC. + sweepDesc = htlcOfferedRevokeSweepDesc( + req.KeyRing, payHash[:], htlcExpiry, htlcID, + ) + + // Revoked HTLC accepted by remote party (incoming HTLC from their + // side). We sweep this using the revocation key (keyspend). + case input.TaprootHtlcAcceptedRevoke: + // Filter for the specific HTLC we're sweeping. We sent this + // HTLC to the remote party (they accepted/received it), so from + // their PoV it's incoming and stored in their + // IncomingHtlcAssets. We sweep it using the revocation key + // since they broadcast a revoked state. + htlcID := req.HtlcID.UnwrapOr(math.MaxUint64) + htlcOutputs := commitState.IncomingHtlcAssets.Val + assetOutputs = htlcOutputs.FilterByHtlcIndex(htlcID) + + payHash, err := req.PayHash.UnwrapOrErr(errNoPayHash) + if err != nil { + return lfn.Err[tlv.Blob](err) + } + + // Create sweep descriptor for revoked accepted HTLC. + sweepDesc = htlcAcceptedRevokeSweepDesc( + req.KeyRing, payHash[:], htlcID, + ) + + // Revoked second-level HTLC transaction. We sweep this using the + // revocation path. + case input.TaprootHtlcSecondLevelRevoke: + // For second-level HTLCs, we need to determine if this was + // originally an offered or accepted HTLC to know which asset + // outputs to filter. + htlcID := req.HtlcID.UnwrapOr(math.MaxUint64) + + // Try outgoing first (offered HTLCs). + htlcOutputs := commitState.OutgoingHtlcAssets.Val + assetOutputs = htlcOutputs.FilterByHtlcIndex(htlcID) + + // Determine CLTV timeout: incoming HTLCs on the remote + // party's commitment need a timeout for the second-level + // transaction. + var cltvTimeout fn.Option[uint32] + + // If not found in outgoing, try incoming (accepted HTLCs). + if len(assetOutputs) == 0 { + log.Debugf("HTLC ID %d not found in outgoing "+ + "assets, trying incoming", htlcID) + + htlcOutputs = commitState.IncomingHtlcAssets.Val + assetOutputs = htlcOutputs.FilterByHtlcIndex(htlcID) + + // Incoming HTLCs on the remote commitment need a + // CLTV timeout for the second-level tx. + req.CltvDelay.WhenSome(func(v uint32) { + cltvTimeout = fn.Some(v) + }) + } + // Save the commitment-level asset outputs before they're + // replaced, as we need them for importing the second-level + // tx (which takes commitment-level inputs). + commitAssetOutputs := assetOutputs + + // Construct a minimal AuxChanState for the second-level + // packet creation. + auxChanState := lnwallet.AuxChanState{ + ChanType: req.ChanType, + IsInitiator: req.Initiator, + LocalChanCfg: channeldb.ChannelConfig{ + CommitmentParams: channeldb.CommitmentParams{ + CsvDelay: uint16(req.CsvDelay), + }, + }, + } + + // Create the second-level virtual packets. This gives us + // both the aux leaf (for the sweep descriptor) AND the + // output assets with the correct second-level script keys. + secondLevelPkts, secondLevelAllocs, err := + CreateSecondLevelHtlcPackets( + auxChanState, req.CommitTx, req.HtlcAmt, + *req.KeyRing, &a.cfg.ChainParams, + assetOutputs, cltvTimeout, htlcID, + ) + if err != nil { + return lfn.Errf[returnType]("unable to create "+ + "second-level packets: %w", err) + } + + // Compute the aux leaf from the allocations (same as + // CreateSecondLevelHtlcTx does). + var opts []tapsend.OutputCommitmentOption + if !commitState.STXO.Val { + opts = append( + opts, tapsend.WithNoSTXOProofs(), + ) + } + outCommitments, err := tapsend.CreateOutputCommitments( + secondLevelPkts, opts..., + ) + if err != nil { + return lfn.Errf[returnType]("unable to create "+ + "output commitments: %w", err) + } + err = tapsend.AssignOutputCommitments( + secondLevelAllocs, outCommitments, + ) + if err != nil { + return lfn.Errf[returnType]("unable to assign "+ + "output commitments: %w", err) + } + auxLeafVal, err := secondLevelAllocs[0].AuxLeaf() + if err != nil { + return lfn.Errf[returnType]("unable to create "+ + "aux leaf: %w", err) + } + secondLevelAuxLeaf := lfn.Some(auxLeafVal) + + // Before importing, re-anchor the commitment-level asset + // outputs to the real commitment tx. They currently + // reference the fake pre-signing tx. + ctxImport := context.Background() + if err := reanchorAssetOutputs( + ctxImport, a.cfg.ChainBridge, *req.CommitTx, + req.CommitTxBlockHeight, commitAssetOutputs, + ); err != nil { + log.Warnf("Unable to re-anchor commit asset "+ + "outputs for second-level import: %v", err) + } + + // Import the second-level tx into the proof archive. This + // creates the commitment → second-level proof transition + // so the sweep can build a valid proof chain. + // We create FRESH vPackets for import since the ones above + // were already used for output commitment computation. + if req.SecondLevelTx != nil { + importPkts, importAllocs, importErr := + CreateSecondLevelHtlcPackets( + auxChanState, req.CommitTx, + req.HtlcAmt, *req.KeyRing, + &a.cfg.ChainParams, + commitAssetOutputs, cltvTimeout, + htlcID, + ) + if importErr != nil { + log.Errorf("Unable to create import "+ + "packets: %v", importErr) + } else { + importErr = a.importSecondLevelHtlcTx( + req, importPkts, + importAllocs, commitState, + ) + if importErr != nil { + log.Errorf("Unable to import "+ + "second-level HTLC "+ + "tx: %v", importErr) + } + } + } + + // Replace the commitment-level asset outputs with the + // second-level outputs from the vPackets. The second-level + // outputs have the correct script keys for the second-level + // taproot tree, which is what the revocation sweep needs to + // sign against. + // + // We also update the proof's PrevOut to reference the + // second-level tx's output, so the sweep proof's + // TxSpendsPrevOut check passes. + var secondLevelAssetOutputs []*cmsg.AssetOutput + for i, vPkt := range secondLevelPkts { + if len(vPkt.Outputs) == 0 || i >= len(assetOutputs) { + continue + } + + outAsset := vPkt.Outputs[0].Asset + + // Create a proof using the commitment-level proof + // as base, updated with the second-level asset. + secondLevelProof := assetOutputs[i].Proof.Val + secondLevelProof.Asset = *outAsset + + // Update the proof's outpoint to reference the + // second-level tx. Find the HTLC output in the + // second-level tx by matching the smallest-value + // P2TR output (the HTLC output carries dust BTC, + // while wallet change carries much more). + if req.SecondLevelTx != nil { + stx := req.SecondLevelTx + stxHash := stx.TxHash() + + // Find the HTLC output index in the + // second-level tx. It's the output + // with the smallest value (HTLC dust). + htlcOutIdx := uint32(0) + minVal := int64(math.MaxInt64) + for idx, txOut := range stx.TxOut { + if txOut.Value < minVal { + minVal = txOut.Value + htlcOutIdx = uint32(idx) + } + } + + secondLevelProof.PrevOut = wire.OutPoint{ + Hash: stxHash, + Index: htlcOutIdx, + } + } + + secondLevelAssetOutputs = append( + secondLevelAssetOutputs, + cmsg.NewAssetOutput( + assetOutputs[i].AssetID.Val, + assetOutputs[i].Amount.Val, + secondLevelProof, + ), + ) + } + + if len(secondLevelAssetOutputs) > 0 { + assetOutputs = secondLevelAssetOutputs + } + + log.Infof("Second-level revoke: htlcID=%d, "+ + "numAssetOutputs=%d, auxLeaf=%v, "+ + "csvDelay=%d", + htlcID, len(assetOutputs), + secondLevelAuxLeaf.IsSome(), req.CsvDelay) + + for i, ao := range assetOutputs { + log.Infof(" assetOutput[%d]: scriptKey=%x, "+ + "amount=%d", + i, + ao.Proof.Val.Asset.ScriptKey.PubKey. + SerializeCompressed(), + ao.Amount.Val) + } + + // Create sweep descriptor for revoked second-level HTLC. + sweepDesc = htlcSecondLevelRevokeSweepDesc( + req.KeyRing, req.CsvDelay, htlcID, + secondLevelAuxLeaf, + ) + + default: return lfn.Errf[returnType]("unknown resolution type: %v", req.Type) } @@ -1949,12 +2591,21 @@ func (a *AuxSweeper) resolveContract( // The input proofs above were made originally using the fake commit tx // as an anchor. We now know the real commit tx, so we'll bind each // proof to the actual commitment output that carries the asset. - if err := reanchorAssetOutputs( - ctx, a.cfg.ChainBridge, commitTx, req.CommitTxBlockHeight, - assetOutputs, - ); err != nil { - return lfn.Errf[returnType]("unable to re-anchor asset "+ - "outputs: %w", err) + // For second-level revocations, the asset outputs have been replaced + // with second-level outputs. We re-anchor them to the second-level tx + // instead of the commitment tx. + isSecondLevelRevoke := req.Type == input.TaprootHtlcSecondLevelRevoke + if !isSecondLevelRevoke { + // For second-level revocations, the proof's PrevOut is set + // directly in the asset output creation above. For all other + // types, re-anchor to the commitment tx. + if err := reanchorAssetOutputs( + ctx, a.cfg.ChainBridge, commitTx, + req.CommitTxBlockHeight, assetOutputs, + ); err != nil { + return lfn.Errf[returnType]("unable to re-anchor "+ + "asset outputs: %w", err) + } } log.Infof("Sweeping %v asset outputs (second_level=%v): %v", @@ -2392,7 +3043,7 @@ func sweepExclusionProofGen(sweepInternalKey keychain.KeyDescriptor, // transition proof for it, then registering the sweep with the porter. func (a *AuxSweeper) registerAndBroadcastSweep(req *sweep.BumpRequest, sweepTx *wire.MsgTx, fee btcutil.Amount, - outpointToTxIndex map[wire.OutPoint]int) error { + outpointToTxIndex map[wire.OutPoint]int, skipBroadcast bool) error { // TODO(roasbeef): need to handle replacement -- will porter just // upsert in place? @@ -2454,6 +3105,35 @@ func (a *AuxSweeper) registerAndBroadcastSweep(req *sweep.BumpRequest, return err } + // For first level outputs, ensure PrevID.OutPoint matches the actual + // BTC input outpoint. This is critical for second-level revoke inputs + // where the vPacket's PrevID.OutPoint still references the commitment + // HTLC output, but the justice tx actually spends the second-level tx + // output. Without this update, the Porter cannot find the input proof. + for _, sweepSet := range vPkts.firstLevel { + actualOutpoint := sweepSet.btcInput.OutPoint() + for _, vPkt := range sweepSet.vPkts { + for _, vIn := range vPkt.Inputs { + if vIn.PrevID.OutPoint != actualOutpoint { + log.Infof("Updating firstLevel "+ + "PrevID.OutPoint from %v "+ + "to %v", + vIn.PrevID.OutPoint, + actualOutpoint) + + vIn.PrevID.OutPoint = actualOutpoint + } + } + + for _, vOut := range vPkt.Outputs { + prevWit := &vOut.Asset.PrevWitnesses[0] + if prevWit.PrevID.OutPoint != actualOutpoint { + prevWit.PrevID.OutPoint = actualOutpoint + } + } + } + } + // For any second level outputs we're sweeping, we'll need to sign for // it, as now we know the txid of the sweeping transaction. for _, sweepSet := range vPkts.secondLevel { @@ -2588,9 +3268,25 @@ func (a *AuxSweeper) registerAndBroadcastSweep(req *sweep.BumpRequest, // With the output commitments re-created, we have all we need to log // and ship the transaction. + // The skipBroadcast flag is set by the caller (LND) based on whether + // the transaction is already confirmed on-chain (e.g. breach sweeps, + // force close commitments) or needs to be broadcast (normal sweeps). + // + // When the transaction is already confirmed (skipBroadcast=true), we + // also skip asset proof verification because the input proofs may + // contain placeholder witnesses (e.g. second-level HTLC outputs) that + // cannot pass VM-level validation. The on-chain confirmation serves + // as proof of validity. + var parcelOpts []tapfreighter.PreAnchoredParcelOpt + if skipBroadcast { + parcelOpts = append( + parcelOpts, tapfreighter.WithSkipProofVerify(), + ) + } + return shipChannelTxn( a.cfg.TxSender, sweepTx, outCommitments, allVpkts, int64(fee), - heightHint, + heightHint, skipBroadcast, parcelOpts..., ) } @@ -2612,7 +3308,7 @@ func (a *AuxSweeper) contractResolver() { case req := <-a.broadcastReqs: req.resp <- a.registerAndBroadcastSweep( req.req, req.tx, req.fee, - req.outpointToTxIndex, + req.outpointToTxIndex, req.skipBroadcast, ) case <-a.quit: @@ -2707,13 +3403,14 @@ func (a *AuxSweeper) ExtraBudgetForInputs( // sweep transaction, generated by the passed BumpRequest. func (a *AuxSweeper) NotifyBroadcast(req *sweep.BumpRequest, tx *wire.MsgTx, fee btcutil.Amount, - outpointToTxIndex map[wire.OutPoint]int) error { + outpointToTxIndex map[wire.OutPoint]int, skipBroadcast bool) error { auxReq := &broadcastReq{ req: req, tx: tx, fee: fee, outpointToTxIndex: outpointToTxIndex, + skipBroadcast: skipBroadcast, resp: make(chan error, 1), } diff --git a/tapchannel/aux_sweeper_test.go b/tapchannel/aux_sweeper_test.go new file mode 100644 index 0000000000..0adfd173de --- /dev/null +++ b/tapchannel/aux_sweeper_test.go @@ -0,0 +1,167 @@ +package tapchannel + +import ( + "crypto/sha256" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/txscript" + lfn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/require" +) + +// TestRevocationSweepDescSignVerify tests that the revocation sweep descriptor +// functions produce taproot output keys consistent with the signing key derived +// from the same base material. For each revocation type (offered, accepted, +// second-level), it performs a full sign+verify round-trip using the same +// routines used in production to create the scripts and derive the keys. +func TestRevocationSweepDescSignVerify(t *testing.T) { + t.Parallel() + + // Generate base key material. In production, revokeBasePriv is our + // revocation base point secret, and commitSecret is the per-commitment + // secret revealed when the remote party broadcasts a revoked + // commitment. + revokeBasePriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + commitSecret, err := btcec.NewPrivateKey() + require.NoError(t, err) + + // Generate HTLC keys and delay key for the commitment keyring. + localHtlcPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + remoteHtlcPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + toLocalPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + // Derive the revocation public key using the standard LND routine. + // This key becomes the internal key of all HTLC taproot outputs. + revocationKey := input.DeriveRevocationPubkey( + revokeBasePriv.PubKey(), commitSecret.PubKey(), + ) + + keyRing := &lnwallet.CommitmentKeyRing{ + RevocationKey: revocationKey, + LocalHtlcKey: localHtlcPriv.PubKey(), + RemoteHtlcKey: remoteHtlcPriv.PubKey(), + ToLocalKey: toLocalPriv.PubKey(), + } + + payHash := sha256.Sum256([]byte("test preimage")) + htlcIndex := input.HtlcIndex(42) + csvDelay := uint32(144) + htlcExpiry := uint32(800_000) + + // Derive the signing private key that the LND signer computes when + // processing a breach sweep: + // 1. DeriveRevocationPrivKey (DoubleTweak) — recovers the revocation + // private key from our base secret and the revealed commit secret. + // 2. TweakPrivKey with HTLC index (SingleTweak) — applies the + // asset-level HTLC index tweak. + revocationPriv := input.DeriveRevocationPrivKey( + revokeBasePriv, commitSecret, + ) + + tweakScalar := ScriptKeyTweakFromHtlcIndex(htlcIndex) + var singleTweak [32]byte + tweakScalar.PutBytesUnchecked(singleTweak[:]) + signingPriv := input.TweakPrivKey(revocationPriv, singleTweak[:]) + + // Verify that the private key tweak path is consistent with the public + // key tweak path. This confirms that TweakPrivKey + SingleTweak on the + // private side produces the same result as TweakPubKeyWithTweak on the + // public side. + derivedInternalKey := input.TweakPubKeyWithTweak( + revocationKey, singleTweak[:], + ) + require.Equal( + t, derivedInternalKey.SerializeCompressed(), + signingPriv.PubKey().SerializeCompressed(), + "private key tweak path should match public key tweak path", + ) + + testCases := []struct { + name string + getSweepDescs func() lfn.Result[tapscriptSweepDescs] + }{ + { + name: "offered HTLC revocation", + getSweepDescs: func() lfn.Result[tapscriptSweepDescs] { + return htlcOfferedRevokeSweepDesc( + keyRing, payHash[:], htlcExpiry, + htlcIndex, + ) + }, + }, + { + name: "accepted HTLC revocation", + getSweepDescs: func() lfn.Result[tapscriptSweepDescs] { + return htlcAcceptedRevokeSweepDesc( + keyRing, payHash[:], htlcIndex, + ) + }, + }, + { + name: "second-level HTLC revocation", + getSweepDescs: func() lfn.Result[tapscriptSweepDescs] { + return htlcSecondLevelRevokeSweepDesc( + keyRing, csvDelay, htlcIndex, + lfn.None[txscript.TapLeaf](), + ) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Get the sweep descriptor. + descs := tc.getSweepDescs().UnwrapOrFail(t) + desc := descs.firstLevel + + // Revocation sweeps use keyspend (no control block). + require.Empty(t, desc.ctrlBlockBytes, + "revocation sweep should use keyspend") + + // Verify the descriptor's internal key matches what + // we derived from applying both tweaks to the base + // keys on the public key side. + tree := desc.scriptTree.Tree() + require.Equal( + t, + derivedInternalKey.SerializeCompressed(), + tree.InternalKey.SerializeCompressed(), + "descriptor internal key should match "+ + "derived key", + ) + + // Apply the taproot tweak for keyspend signing. + // This mirrors what RawTxInTaprootSignature does + // internally. + tapTweak := desc.scriptTree.TapTweak() + taprootPriv := txscript.TweakTaprootPrivKey( + *signingPriv, tapTweak, + ) + + // Sign a test message. + testMsg := sha256.Sum256([]byte(tc.name)) + sig, err := schnorr.Sign(taprootPriv, testMsg[:]) + require.NoError(t, err) + + // Verify the signature against the taproot output + // key from the descriptor. This is the key that the + // UTXO is locked to on-chain. + require.True( + t, sig.Verify(testMsg[:], tree.TaprootKey), + "signature should verify against taproot "+ + "output key", + ) + }) + } +} diff --git a/tapchannel/commitment.go b/tapchannel/commitment.go index e96bccd0ca..fe6a88a310 100644 --- a/tapchannel/commitment.go +++ b/tapchannel/commitment.go @@ -410,6 +410,7 @@ func SanityCheckAmounts(ourBalance, theirBalance btcutil.Amount, if !lnwallet.HtlcIsDust( chanType, false, whoseCommit, feePerKw, entry.Amount.ToSatoshis(), dustLimit, + true, ) { numHTLCs++ @@ -419,6 +420,7 @@ func SanityCheckAmounts(ourBalance, theirBalance btcutil.Amount, if !lnwallet.HtlcIsDust( chanType, true, whoseCommit, feePerKw, entry.Amount.ToSatoshis(), dustLimit, + true, ) { numHTLCs++ @@ -431,6 +433,7 @@ func SanityCheckAmounts(ourBalance, theirBalance btcutil.Amount, isDust := lnwallet.HtlcIsDust( chanType, false, whoseCommit, feePerKw, entry.Amount.ToSatoshis(), dustLimit, + true, ) if rfqmsg.Sum(entry.AssetBalances) > 0 && isDust { return false, false, fmt.Errorf("outgoing HTLC asset "+ @@ -446,6 +449,7 @@ func SanityCheckAmounts(ourBalance, theirBalance btcutil.Amount, isDust := lnwallet.HtlcIsDust( chanType, true, whoseCommit, feePerKw, entry.Amount.ToSatoshis(), dustLimit, + true, ) if rfqmsg.Sum(entry.AssetBalances) > 0 && isDust { return false, false, fmt.Errorf("incoming HTLC asset "+ @@ -497,8 +501,9 @@ func GenerateCommitmentAllocations(prevState *cmsg.Commitment, whoseCommit lntypes.ChannelParty, ourBalance, theirBalance lnwire.MilliSatoshi, originalView lnwallet.AuxHtlcView, chainParams *address.ChainParams, - keys lnwallet.CommitmentKeyRing, stxo bool) ([]*tapsend.Allocation, - *cmsg.Commitment, error) { + keys lnwallet.CommitmentKeyRing, stxo, + sigHashDefault bool) ([]*tapsend.Allocation, *cmsg.Commitment, + error) { log.Tracef("Generating allocations, whoseCommit=%v, ourBalance=%d, "+ "theirBalance=%d", whoseCommit, ourBalance, theirBalance) @@ -651,7 +656,9 @@ func GenerateCommitmentAllocations(prevState *cmsg.Commitment, // Next, we can convert the allocations to auxiliary leaves and from // those construct our Commitment struct that will in the end also hold // our proof suffixes. - newCommitment, err := ToCommitment(allocations, vPackets, stxo) + newCommitment, err := ToCommitment( + allocations, vPackets, stxo, sigHashDefault, + ) if err != nil { return nil, nil, fmt.Errorf("unable to convert to commitment: "+ "%w", err) @@ -785,7 +792,7 @@ func CreateAllocations(chanState lnwallet.AuxChanState, ourBalance, isDust := lnwallet.HtlcIsDust( chanState.ChanType, isIncoming, whoseCommit, filteredView.FeePerKw, htlc.Amount.ToSatoshis(), - dustLimit, + dustLimit, true, ) if isDust { // We need to error out, as a dust HTLC carrying assets @@ -882,7 +889,7 @@ func CreateAllocations(chanState lnwallet.AuxChanState, ourBalance, isDust := lnwallet.HtlcIsDust( chanState.ChanType, isIncoming, whoseCommit, filteredView.FeePerKw, htlc.Amount.ToSatoshis(), - dustLimit, + dustLimit, true, ) if isDust { return nil @@ -1155,7 +1162,8 @@ func LeavesFromTapscriptScriptTree( // ToCommitment converts the allocations to a Commitment struct. func ToCommitment(allocations []*tapsend.Allocation, - vPackets []*tappsbt.VPacket, stxo bool) (*cmsg.Commitment, error) { + vPackets []*tappsbt.VPacket, stxo, + sigHashDefault bool) (*cmsg.Commitment, error) { var ( localAssets []*cmsg.AssetOutput @@ -1278,7 +1286,7 @@ func ToCommitment(allocations []*tapsend.Allocation, return cmsg.NewCommitment( localAssets, remoteAssets, outgoingHtlcs, incomingHtlcs, - auxLeaves, stxo, + auxLeaves, stxo, sigHashDefault, ), nil } diff --git a/tapchannelmsg/custom_channel_data_test.go b/tapchannelmsg/custom_channel_data_test.go index 7ae5a186d2..b01ff84099 100644 --- a/tapchannelmsg/custom_channel_data_test.go +++ b/tapchannelmsg/custom_channel_data_test.go @@ -50,7 +50,7 @@ func TestReadChannelCustomData(t *testing.T) { }, map[input.HtlcIndex][]*AssetOutput{ 2: {output4}, }, lnwallet.CommitAuxLeaves{}, - false, + false, false, ) fundingBlob := fundingState.Bytes() @@ -158,19 +158,19 @@ func TestReadBalanceCustomData(t *testing.T) { openChannel1 := NewCommitment( []*AssetOutput{output1}, []*AssetOutput{output2}, nil, nil, - lnwallet.CommitAuxLeaves{}, false, + lnwallet.CommitAuxLeaves{}, false, false, ) openChannel2 := NewCommitment( []*AssetOutput{output2}, []*AssetOutput{output3}, nil, nil, - lnwallet.CommitAuxLeaves{}, false, + lnwallet.CommitAuxLeaves{}, false, false, ) pendingChannel1 := NewCommitment( []*AssetOutput{output3}, nil, nil, nil, - lnwallet.CommitAuxLeaves{}, false, + lnwallet.CommitAuxLeaves{}, false, false, ) pendingChannel2 := NewCommitment( nil, []*AssetOutput{output1}, nil, nil, - lnwallet.CommitAuxLeaves{}, false, + lnwallet.CommitAuxLeaves{}, false, false, ) var customChannelData bytes.Buffer diff --git a/tapchannelmsg/records.go b/tapchannelmsg/records.go index b93d027dec..0a9ab9b294 100644 --- a/tapchannelmsg/records.go +++ b/tapchannelmsg/records.go @@ -457,13 +457,20 @@ type Commitment struct { // STXO is a flag indicating whether this commitment supports stxo // proofs. STXO tlv.RecordT[tlv.TlvType5, bool] + + // SigHashDefault is a flag indicating whether HTLC second-level + // transactions for this commitment use SigHashDefault. This is cached + // from the negotiated feature bits so that it is available after + // restart without requiring the peer to be online. + SigHashDefault tlv.RecordT[tlv.TlvType6, bool] } // NewCommitment creates a new Commitment record with the given local and remote // assets, and incoming and outgoing HTLCs. func NewCommitment(localAssets, remoteAssets []*AssetOutput, outgoingHtlcs, incomingHtlcs map[input.HtlcIndex][]*AssetOutput, - auxLeaves lnwallet.CommitAuxLeaves, stxo bool) *Commitment { + auxLeaves lnwallet.CommitAuxLeaves, stxo, + sigHashDefault bool) *Commitment { return &Commitment{ LocalAssets: tlv.NewRecordT[tlv.TlvType0]( @@ -490,6 +497,9 @@ func NewCommitment(localAssets, remoteAssets []*AssetOutput, outgoingHtlcs, ), ), STXO: tlv.NewPrimitiveRecord[tlv.TlvType5](stxo), + SigHashDefault: tlv.NewPrimitiveRecord[tlv.TlvType6]( + sigHashDefault, + ), } } @@ -502,6 +512,7 @@ func (c *Commitment) records() []tlv.Record { c.IncomingHtlcAssets.Record(), c.AuxLeaves.Record(), c.STXO.Record(), + c.SigHashDefault.Record(), } } diff --git a/tapchannelmsg/records_test.go b/tapchannelmsg/records_test.go index 8a1d2cf7e0..ca1bfb4483 100644 --- a/tapchannelmsg/records_test.go +++ b/tapchannelmsg/records_test.go @@ -215,7 +215,7 @@ func TestCommitment(t *testing.T) { name: "commitment with empty HTLC maps", commitment: NewCommitment( nil, nil, nil, nil, lnwallet.CommitAuxLeaves{}, - false, + false, false, ), }, { @@ -229,7 +229,8 @@ func TestCommitment(t *testing.T) { NewAssetOutput( [32]byte{1}, 1000, *randProof, ), - }, nil, nil, lnwallet.CommitAuxLeaves{}, false, + }, nil, nil, + lnwallet.CommitAuxLeaves{}, false, false, ), }, { @@ -243,7 +244,8 @@ func TestCommitment(t *testing.T) { NewAssetOutput( [32]byte{1}, 1000, *randProof, ), - }, nil, nil, lnwallet.CommitAuxLeaves{}, true, + }, nil, nil, + lnwallet.CommitAuxLeaves{}, true, false, ), }, { @@ -334,7 +336,7 @@ func TestCommitment(t *testing.T) { }, }, }, - false, + false, false, ), }, } diff --git a/tapfeatures/aux_feature_bits.go b/tapfeatures/aux_feature_bits.go index 135e1e3a19..59cf50ca56 100644 --- a/tapfeatures/aux_feature_bits.go +++ b/tapfeatures/aux_feature_bits.go @@ -18,14 +18,29 @@ const ( // STXOOptional is a feature bit that declares the STXO proofs as an // optional feature. STXOOptional lnwire.FeatureBit = 3 + + // SigHashDefaultHTLCsRequired is a feature bit that declares + // SigHashDefault for HTLC second-level transactions as a required + // feature. When this feature is negotiated, HTLC second-level + // transactions use SigHashDefault instead of + // SigHashSingle|AnyOneCanPay, which keeps the transaction + // deterministic for asset proof construction. + SigHashDefaultHTLCsRequired lnwire.FeatureBit = 4 + + // SigHashDefaultHTLCsOptional is a feature bit that declares + // SigHashDefault for HTLC second-level transactions as an optional + // feature. + SigHashDefaultHTLCsOptional lnwire.FeatureBit = 5 ) // featureNames keeps track of the string description of known features. var featureNames = map[lnwire.FeatureBit]string{ - NoOpHTLCsRequired: "noop-htlcs", - NoOpHTLCsOptional: "noop-htlcs", - STXORequired: "stxo-proofs", - STXOOptional: "stxo-proofs", + NoOpHTLCsRequired: "noop-htlcs", + NoOpHTLCsOptional: "noop-htlcs", + STXORequired: "stxo-proofs", + STXOOptional: "stxo-proofs", + SigHashDefaultHTLCsRequired: "sighash-default-htlcs", + SigHashDefaultHTLCsOptional: "sighash-default-htlcs", } // ourFeatures returns a slice containing all of the locally supported features. @@ -35,6 +50,7 @@ func ourFeatures() []lnwire.FeatureBit { return []lnwire.FeatureBit{ NoOpHTLCsOptional, STXOOptional, + SigHashDefaultHTLCsOptional, } } diff --git a/tapfreighter/chain_porter.go b/tapfreighter/chain_porter.go index c59c5a2e98..5677f6868b 100644 --- a/tapfreighter/chain_porter.go +++ b/tapfreighter/chain_porter.go @@ -609,19 +609,33 @@ func (p *ChainPorter) storeProofs(sendPkg *sendPackage) error { } sendPkg.FinalProofs[outKey] = outputProof - vCtx := proof.VerifierCtx{ - HeaderVerifier: headerVerifier, - MerkleVerifier: proof.DefaultMerkleVerifier, - GroupVerifier: p.cfg.GroupVerifier, - ChainLookupGen: p.cfg.ChainBridge, - IgnoreChecker: p.cfg.IgnoreChecker, - } + var verifiedOutputProofs []proof.VerifiedAnnotatedProof + if sendPkg.SkipProofVerify { + // For already-confirmed channel txs with + // placeholder witnesses, skip the VM-level + // verification and trust the on-chain + // confirmation. + verifiedOutputProofs = + proof.AssumeVerifiedAnnotatedProofs( + outputProof, + ) + } else { + vCtx := proof.VerifierCtx{ + HeaderVerifier: headerVerifier, + MerkleVerifier: proof.DefaultMerkleVerifier, + GroupVerifier: p.cfg.GroupVerifier, + ChainLookupGen: p.cfg.ChainBridge, + IgnoreChecker: p.cfg.IgnoreChecker, + } - verifiedOutputProofs, err := proof.VerifyAnnotatedProofs( - ctx, vCtx, outputProof, - ) - if err != nil { - return fmt.Errorf("error verifying proof: %w", err) + verifiedOutputProofs, err = + proof.VerifyAnnotatedProofs( + ctx, vCtx, outputProof, + ) + if err != nil { + return fmt.Errorf("error verifying "+ + "proof: %w", err) + } } // Import proof into proof archive. diff --git a/tapfreighter/parcel.go b/tapfreighter/parcel.go index e7d335e241..db7cf69cdc 100644 --- a/tapfreighter/parcel.go +++ b/tapfreighter/parcel.go @@ -436,19 +436,41 @@ type PreAnchoredParcel struct { // anchorTxHeightHint is an optional height hint for the anchor // transaction. anchorTxHeightHint fn.Option[uint32] + + // skipProofVerify skips the output proof verification step. This is + // used when importing already-confirmed channel transactions whose + // asset-level witnesses cannot be reconstructed (e.g. second-level + // HTLC transactions where the HTLC script keys require signatures + // we don't possess). The BTC-level confirmation serves as proof of + // validity. + skipProofVerify bool } // A compile-time assertion to ensure PreAnchoredParcel implements the Parcel // interface. var _ Parcel = (*PreAnchoredParcel)(nil) +// PreAnchoredParcelOpt is a functional option for NewPreAnchoredParcel. +type PreAnchoredParcelOpt func(*PreAnchoredParcel) + +// WithSkipProofVerify returns an option that skips the output proof +// verification step in the porter. This is used when importing +// already-confirmed channel transactions whose asset-level witnesses +// cannot be reconstructed. +func WithSkipProofVerify() PreAnchoredParcelOpt { + return func(p *PreAnchoredParcel) { + p.skipProofVerify = true + } +} + // NewPreAnchoredParcel creates a new PreAnchoredParcel. func NewPreAnchoredParcel(vPackets []*tappsbt.VPacket, passiveAssets []*tappsbt.VPacket, anchorTx *tapsend.AnchorTransaction, skipAnchorTxBroadcast bool, label string, - anchorTxHeightHint fn.Option[uint32]) *PreAnchoredParcel { + anchorTxHeightHint fn.Option[uint32], + opts ...PreAnchoredParcelOpt) *PreAnchoredParcel { - return &PreAnchoredParcel{ + p := &PreAnchoredParcel{ parcelKit: &parcelKit{ respChan: make(chan *OutboundParcel, 1), errChan: make(chan error, 1), @@ -460,6 +482,11 @@ func NewPreAnchoredParcel(vPackets []*tappsbt.VPacket, label: label, anchorTxHeightHint: anchorTxHeightHint, } + for _, opt := range opts { + opt(p) + } + + return p } // pkg returns the send package that should be delivered. @@ -467,16 +494,25 @@ func (p *PreAnchoredParcel) pkg() *sendPackage { log.Infof("New anchored delivery request with %d packets", len(p.virtualPackets)) + // When proof verification is skipped (e.g. for already-confirmed + // channel txs with placeholder witnesses), jump directly to the + // store state. + startState := SendStateVerifyPreBroadcast + if p.skipProofVerify { + startState = SendStateStorePreBroadcast + } + // Initialize a package the signed virtual transaction and input // commitment. return &sendPackage{ Parcel: p, - SendState: SendStateVerifyPreBroadcast, + SendState: startState, VirtualPackets: p.virtualPackets, PassiveAssets: p.passiveAssets, AnchorTx: p.anchorTx, Label: p.label, SkipAnchorTxBroadcast: p.skipAnchorTxBroadcast, + SkipProofVerify: p.skipProofVerify, } } @@ -589,6 +625,12 @@ type sendPackage struct { // broadcast should be skipped. Useful when an external system handles // broadcasting, such as in custom transaction packaging workflows. SkipAnchorTxBroadcast bool + + // SkipProofVerify skips asset witness verification for both + // pre-broadcast and post-confirmation steps. Used when importing + // already-confirmed channel transactions whose asset-level witnesses + // are placeholders. + SkipProofVerify bool } // ConvertToTransfer prepares the finished send data for storing to the database