diff --git a/GWallet.Backend.nuspec b/GWallet.Backend.nuspec index a93db2da4..174f6e8e6 100644 --- a/GWallet.Backend.nuspec +++ b/GWallet.Backend.nuspec @@ -10,7 +10,7 @@ false geewallet is a minimalistic and pragmatist crossplatform lightweight opensource brainwallet for people that want to hold the most important cryptocurrencies in the same application with ease and peace of mind. - + diff --git a/src/GWallet.Backend.Tests.End2End/GWallet.Backend.Tests.End2End.fsproj b/src/GWallet.Backend.Tests.End2End/GWallet.Backend.Tests.End2End.fsproj index cc05aa99a..24741de3d 100644 --- a/src/GWallet.Backend.Tests.End2End/GWallet.Backend.Tests.End2End.fsproj +++ b/src/GWallet.Backend.Tests.End2End/GWallet.Backend.Tests.End2End.fsproj @@ -135,13 +135,13 @@ ..\..\packages\BTCPayServer.Lightning.Ptarmigan.1.2.2\lib\netstandard2.0\BTCPayServer.Lightning.Ptarmigan.dll - ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220204-0457-git-9c7a03c\lib\netstandard2.0\DotNetLightning.Core.dll + ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220322-1031-git-5379324\lib\netstandard2.0\DotNetLightning.Core.dll - ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220204-0457-git-9c7a03c\lib\netstandard2.0\InternalBech32Encoder.dll + ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220322-1031-git-5379324\lib\netstandard2.0\InternalBech32Encoder.dll - ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220204-0457-git-9c7a03c\lib\netstandard2.0\ResultUtils.dll + ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220322-1031-git-5379324\lib\netstandard2.0\ResultUtils.dll ..\..\packages\System.Memory.4.5.4\lib\net461\System.Memory.dll diff --git a/src/GWallet.Backend.Tests.End2End/LN.fs b/src/GWallet.Backend.Tests.End2End/LN.fs index d4dd23f5c..ae808f263 100644 --- a/src/GWallet.Backend.Tests.End2End/LN.fs +++ b/src/GWallet.Backend.Tests.End2End/LN.fs @@ -834,7 +834,6 @@ type LN() = (Node.Server serverWallet.NodeServer).CreateRecoveryTxForRemoteForceClose channelId closingTx - false | _ -> do! Async.Sleep 2000 return! waitForRemoteForceClose() @@ -900,7 +899,6 @@ type LN() = (Node.Client clientWallet.NodeClient).CreateRecoveryTxForRemoteForceClose channelId forceCloseTx - false let recoveryTx = UnwrapResult recoveryTxOpt "no funds could be recovered" let! _recoveryTxId = ChannelManager.BroadcastRecoveryTxAndCloseChannel recoveryTx clientWallet.ChannelStore @@ -1295,6 +1293,30 @@ type LN() = FeeRatePerKw.FromFeeAndVSize(commitmentTxFee, uint64 (commitmentTx.GetVirtualSize())) assert FeesHelper.FeeRatesApproxEqual commitmentTxFeeRate oldFeeRate + let! anchorTxRes = + (Node.Client clientWallet.NodeClient).CreateAnchorFeeBumpForForceClose + channelId + (commitmentTx.ToHex()) + clientWallet.Password + let anchorTxString = + UnwrapResult + anchorTxRes + "force close failed to recover funds from the commitment tx" + let anchorTx = Transaction.Parse(anchorTxString.Tx.ToString(), Network.RegTest) + let! anchorTxFee = FeesHelper.GetFeeFromTransaction anchorTx + let anchorTxFeeRate = + FeeRatePerKw.FromFeeAndVSize(anchorTxFee, uint64 (anchorTx.GetVirtualSize())) + assert (not <| FeesHelper.FeeRatesApproxEqual anchorTxFeeRate oldFeeRate) + assert (not <| FeesHelper.FeeRatesApproxEqual anchorTxFeeRate newFeeRate) + let combinedFeeRate = + FeeRatePerKw.FromFeeAndVSize( + anchorTxFee + commitmentTxFee, + uint64 (anchorTx.GetVirtualSize() + commitmentTx.GetVirtualSize()) + ) + assert FeesHelper.FeeRatesApproxEqual combinedFeeRate newFeeRate + + let! _anchorTxIdString = Account.BroadcastRawTransaction Currency.BTC (anchorTxString.Tx.ToString()) + // Give the fundee time to see the force-close tx do! Async.Sleep 5000 @@ -1351,50 +1373,6 @@ type LN() = FeeRatePerKw.FromFeeAndVSize(forceCloseTxFee, uint64 (forceCloseTx.GetVirtualSize())) assert FeesHelper.FeeRatesApproxEqual forceCloseTxFeeRate oldFeeRate - let! recoveryTxStringNoCpfpRes = - (Node.Server serverWallet.NodeServer).CreateRecoveryTxForRemoteForceClose - channelId - wrappedForceCloseTx - false - let recoveryTxStringNoCpfp = - UnwrapResult - recoveryTxStringNoCpfpRes - "force close failed to recover funds from the commitment tx" - let recoveryTxNoCpfp = Transaction.Parse(recoveryTxStringNoCpfp.Tx.ToString(), Network.RegTest) - let! recoveryTxFeeNoCpfp = FeesHelper.GetFeeFromTransaction recoveryTxNoCpfp - let recoveryTxFeeRateNoCpfp = - FeeRatePerKw.FromFeeAndVSize(recoveryTxFeeNoCpfp, uint64 (recoveryTxNoCpfp.GetVirtualSize())) - assert FeesHelper.FeeRatesApproxEqual recoveryTxFeeRateNoCpfp newFeeRate - let combinedFeeRateNoCpfp = - FeeRatePerKw.FromFeeAndVSize( - recoveryTxFeeNoCpfp + forceCloseTxFee, - uint64 (recoveryTxNoCpfp.GetVirtualSize() + forceCloseTx.GetVirtualSize()) - ) - assert (not <| FeesHelper.FeeRatesApproxEqual combinedFeeRateNoCpfp oldFeeRate) - assert (not <| FeesHelper.FeeRatesApproxEqual combinedFeeRateNoCpfp newFeeRate) - - let! recoveryTxStringWithCpfpRes = - (Node.Server serverWallet.NodeServer).CreateRecoveryTxForRemoteForceClose - channelId - wrappedForceCloseTx - true - let recoveryTxStringWithCpfp = - UnwrapResult - recoveryTxStringWithCpfpRes - "force close failed to recover funds from the commitment tx" - let recoveryTxWithCpfp = Transaction.Parse(recoveryTxStringWithCpfp.Tx.ToString(), Network.RegTest) - let! recoveryTxFeeWithCpfp = FeesHelper.GetFeeFromTransaction recoveryTxWithCpfp - let recoveryTxFeeRateWithCpfp = - FeeRatePerKw.FromFeeAndVSize(recoveryTxFeeWithCpfp, uint64 (recoveryTxWithCpfp.GetVirtualSize())) - assert (not <| FeesHelper.FeeRatesApproxEqual recoveryTxFeeRateWithCpfp oldFeeRate) - assert (not <| FeesHelper.FeeRatesApproxEqual recoveryTxFeeRateWithCpfp newFeeRate) - let combinedFeeRateWithCpfp = - FeeRatePerKw.FromFeeAndVSize( - recoveryTxFeeWithCpfp + forceCloseTxFee, - uint64 (recoveryTxWithCpfp.GetVirtualSize() + forceCloseTx.GetVirtualSize()) - ) - assert FeesHelper.FeeRatesApproxEqual combinedFeeRateWithCpfp newFeeRate - (serverWallet :> IDisposable).Dispose() } diff --git a/src/GWallet.Backend.Tests.End2End/packages.config b/src/GWallet.Backend.Tests.End2End/packages.config index cf06c3faa..ea57b0a76 100644 --- a/src/GWallet.Backend.Tests.End2End/packages.config +++ b/src/GWallet.Backend.Tests.End2End/packages.config @@ -7,7 +7,7 @@ - + diff --git a/src/GWallet.Backend.Tests.Unit/ChannelMarshalling.fs b/src/GWallet.Backend.Tests.Unit/ChannelMarshalling.fs index 899a49e4b..841029cc9 100644 --- a/src/GWallet.Backend.Tests.Unit/ChannelMarshalling.fs +++ b/src/GWallet.Backend.Tests.Unit/ChannelMarshalling.fs @@ -130,6 +130,9 @@ type ChannelMarshalling () = "024172921dc95fd529d4e5418e2d59c8037c1d7ff03c10d9c432bbefdff49bb741" ] } + }, + "type": { + "case": "StaticRemoteKey" } }, "remotePerCommitmentSecrets": [], diff --git a/src/GWallet.Backend.Tests.Unit/GWallet.Backend.Tests.Unit.fsproj b/src/GWallet.Backend.Tests.Unit/GWallet.Backend.Tests.Unit.fsproj index ba19fcdf3..fb06d86da 100644 --- a/src/GWallet.Backend.Tests.Unit/GWallet.Backend.Tests.Unit.fsproj +++ b/src/GWallet.Backend.Tests.Unit/GWallet.Backend.Tests.Unit.fsproj @@ -163,13 +163,13 @@ ..\..\packages\System.Memory.4.5.4\lib\net461\System.Memory.dll - ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220204-0457-git-9c7a03c\lib\netstandard2.0\DotNetLightning.Core.dll + ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220322-1031-git-5379324\lib\netstandard2.0\DotNetLightning.Core.dll - ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220204-0457-git-9c7a03c\lib\netstandard2.0\InternalBech32Encoder.dll + ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220322-1031-git-5379324\lib\netstandard2.0\InternalBech32Encoder.dll - ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220204-0457-git-9c7a03c\lib\netstandard2.0\ResultUtils.dll + ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220322-1031-git-5379324\lib\netstandard2.0\ResultUtils.dll ..\..\packages\Portable.BouncyCastle.1.8.10\lib\net40\BouncyCastle.Crypto.dll diff --git a/src/GWallet.Backend.Tests.Unit/packages.config b/src/GWallet.Backend.Tests.Unit/packages.config index adab0bcf6..dafb1b877 100644 --- a/src/GWallet.Backend.Tests.Unit/packages.config +++ b/src/GWallet.Backend.Tests.Unit/packages.config @@ -1,6 +1,6 @@  - + diff --git a/src/GWallet.Backend/GWallet.Backend.fsproj b/src/GWallet.Backend/GWallet.Backend.fsproj index 92b2c8122..a08042bee 100644 --- a/src/GWallet.Backend/GWallet.Backend.fsproj +++ b/src/GWallet.Backend/GWallet.Backend.fsproj @@ -76,6 +76,7 @@ + @@ -333,13 +334,13 @@ ..\..\packages\System.Memory.4.5.4\lib\net461\System.Memory.dll - ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220204-0457-git-9c7a03c\lib\netstandard2.0\DotNetLightning.Core.dll + ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220322-1031-git-5379324\lib\netstandard2.0\DotNetLightning.Core.dll - ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220204-0457-git-9c7a03c\lib\netstandard2.0\InternalBech32Encoder.dll + ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220322-1031-git-5379324\lib\netstandard2.0\InternalBech32Encoder.dll - ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220204-0457-git-9c7a03c\lib\netstandard2.0\ResultUtils.dll + ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220322-1031-git-5379324\lib\netstandard2.0\ResultUtils.dll ..\..\packages\FSharpx.Collections.3.0.1\lib\netstandard2.0\FSharpx.Collections.dll diff --git a/src/GWallet.Backend/UtxoCoin/Lightning/ChainWatcher.fs b/src/GWallet.Backend/UtxoCoin/Lightning/ChainWatcher.fs index 60463137e..e9737a389 100644 --- a/src/GWallet.Backend/UtxoCoin/Lightning/ChainWatcher.fs +++ b/src/GWallet.Backend/UtxoCoin/Lightning/ChainWatcher.fs @@ -82,7 +82,17 @@ module public ChainWatcher = } - return! ListAsyncTryPick historyList checkIfRevokedCommitment + return! + ListAsyncTryPick + historyList + (fun txInfo -> + // Only check spending txs with at least 1 conf, cause we need at least 1 conf to broadcast our + // penalty tx (because it tries to spend to_remote output as well, which is time-locked for one block) + if txInfo.Height > 0u then + checkIfRevokedCommitment txInfo + else + async { return None }) + } let CheckForChannelFraudsAndSendRevocationTx (accounts: seq) diff --git a/src/GWallet.Backend/UtxoCoin/Lightning/ChannelManagement.fs b/src/GWallet.Backend/UtxoCoin/Lightning/ChannelManagement.fs index c9c1b8987..2eec4d245 100644 --- a/src/GWallet.Backend/UtxoCoin/Lightning/ChannelManagement.fs +++ b/src/GWallet.Backend/UtxoCoin/Lightning/ChannelManagement.fs @@ -42,6 +42,7 @@ type LocallyForceClosedData = Currency: Currency ToSelfDelay: uint16 ForceCloseTxId: TransactionIdentifier + ClosingTimestampUtc: DateTime } member self.GetRemainingConfirmations (): Async = async { @@ -97,6 +98,7 @@ type ChannelInfo = Currency = currency ToSelfDelay = serializedChannel.SavedChannelState.StaticChannelConfig.LocalParams.ToSelfDelay.Value ForceCloseTxId = forceCloseTxId + ClosingTimestampUtc = UnwrapOption serializedChannel.ClosingTimestampUtc "BUG: closing date is empty after local force close" } | None -> if serializedChannel.NegotiatingState.HasEnteredShutdown() then diff --git a/src/GWallet.Backend/UtxoCoin/Lightning/ForceCloseTransaction.fs b/src/GWallet.Backend/UtxoCoin/Lightning/ForceCloseTransaction.fs index ca03746c7..ce6d0d039 100644 --- a/src/GWallet.Backend/UtxoCoin/Lightning/ForceCloseTransaction.fs +++ b/src/GWallet.Backend/UtxoCoin/Lightning/ForceCloseTransaction.fs @@ -40,7 +40,8 @@ module public ForceCloseTransaction = (Commitments.RemoteCommitAmount savedChannelState.StaticChannelConfig.IsFunder savedChannelState.StaticChannelConfig.RemoteParams - savedChannelState.RemoteCommit) + savedChannelState.RemoteCommit + savedChannelState.StaticChannelConfig.Type.CommitmentFormat) .ToLocal .ToDecimal(MoneyUnit.Satoshi) @@ -48,7 +49,8 @@ module public ForceCloseTransaction = (Commitments.RemoteCommitAmount savedChannelState.StaticChannelConfig.IsFunder savedChannelState.StaticChannelConfig.RemoteParams - savedChannelState.RemoteCommit) + savedChannelState.RemoteCommit + savedChannelState.StaticChannelConfig.Type.CommitmentFormat) .ToRemote .ToDecimal(MoneyUnit.Satoshi) diff --git a/src/GWallet.Backend/UtxoCoin/Lightning/Node.fs b/src/GWallet.Backend/UtxoCoin/Lightning/Node.fs index 6ee5ceca2..a775b9b32 100644 --- a/src/GWallet.Backend/UtxoCoin/Lightning/Node.fs +++ b/src/GWallet.Backend/UtxoCoin/Lightning/Node.fs @@ -3,6 +3,7 @@ namespace GWallet.Backend.UtxoCoin.Lightning open System open System.IO open System.Net +open System.Linq open NBitcoin open DotNetLightning.Chain @@ -17,17 +18,22 @@ open NOnion.Network open GWallet.Backend open GWallet.Backend.UtxoCoin +open GWallet.Backend.UtxoCoin.Account +open GWallet.Backend.UtxoCoin.Lightning.Validation open GWallet.Backend.FSharpUtil open GWallet.Backend.FSharpUtil.UwpHacks type internal NodeOpenChannelError = | Connect of ConnectError + | InitMsgValidation of InitMsgValidationError | OpenChannel of OpenChannelError interface IErrorMsg with member self.Message = match self with | Connect connectError -> SPrintF1 "error connecting: %s" (connectError :> IErrorMsg).Message + | InitMsgValidation validationError -> + SPrintF1 "invalid remote party initialization: %s" (validationError :> IErrorMsg).Message | OpenChannel openChannelError -> SPrintF1 "error opening channel: %s" (openChannelError :> IErrorMsg).Message member __.ChannelBreakdown: bool = @@ -35,12 +41,15 @@ type internal NodeOpenChannelError = type internal NodeAcceptChannelError = | AcceptPeer of ConnectError + | InitMsgValidation of InitMsgValidationError | AcceptChannel of AcceptChannelError interface IErrorMsg with member self.Message = match self with | AcceptPeer connectError -> SPrintF1 "error accepting connection: %s" (connectError :> IErrorMsg).Message + | InitMsgValidation validationError -> + SPrintF1 "invalid remote party initialization: %s" (validationError :> IErrorMsg).Message | AcceptChannel acceptChannelError -> SPrintF1 "error accepting channel: %s" (acceptChannelError :> IErrorMsg).Message member __.ChannelBreakdown: bool = @@ -254,25 +263,29 @@ type NodeClient internal (channelStore: ChannelStore, nodeMasterPrivKey: NodeMas |> ignore return Error <| (NodeOpenChannelError.Connect connectError :> IErrorMsg) | Ok peerNode -> - let! outgoingUnfundedChannelRes = - OutgoingUnfundedChannel.OpenChannel - peerNode - self.Account - channelCapacity - match outgoingUnfundedChannelRes with - | Error openChannelError -> - if openChannelError.PossibleBug then - let msg = - SPrintF3 - "error opening channel with peer %s of capacity %M: %s" - (nodeIdentifier.ToString()) - channelCapacity.ValueToSend - (openChannelError :> IErrorMsg).Message - Infrastructure.ReportWarningMessage msg - |> ignore - return Error <| (NodeOpenChannelError.OpenChannel openChannelError :> IErrorMsg) - | Ok outgoingUnfundedChannel -> - return Ok <| PendingChannel(outgoingUnfundedChannel) + match ValidateRemoteInitMsg peerNode.InitMsg with + | Error validationError -> + return Error <| (NodeOpenChannelError.InitMsgValidation validationError :> IErrorMsg) + | Ok _ -> + let! outgoingUnfundedChannelRes = + OutgoingUnfundedChannel.OpenChannel + peerNode + self.Account + channelCapacity + match outgoingUnfundedChannelRes with + | Error openChannelError -> + if openChannelError.PossibleBug then + let msg = + SPrintF3 + "error opening channel with peer %s of capacity %M: %s" + (nodeIdentifier.ToString()) + channelCapacity.ValueToSend + (openChannelError :> IErrorMsg).Message + Infrastructure.ReportWarningMessage msg + |> ignore + return Error (NodeOpenChannelError.OpenChannel openChannelError :> IErrorMsg) + | Ok outgoingUnfundedChannel -> + return Ok <| PendingChannel outgoingUnfundedChannel } member internal self.SendHtlcPayment (channelId: ChannelIdentifier) @@ -423,23 +436,27 @@ type NodeServer internal (channelStore: ChannelStore, transportListener: Transpo |> ignore return Error <| (NodeAcceptChannelError.AcceptPeer connectError :> IErrorMsg) | Ok peerNode -> - let! fundedChannelRes = FundedChannel.AcceptChannel peerNode self.Account - match fundedChannelRes with - | Error acceptChannelError -> - if acceptChannelError.PossibleBug then - let msg = - SPrintF2 - "error accepting channel from peer %s: %s" - (peerNode.NodeEndPoint.ToString()) - (acceptChannelError :> IErrorMsg).Message - Infrastructure.ReportWarningMessage msg - |> ignore - return Error <| (NodeAcceptChannelError.AcceptChannel acceptChannelError :> IErrorMsg) - | Ok fundedChannel -> - let channelId = fundedChannel.ChannelId - let txId = fundedChannel.FundingTxId - (fundedChannel :> IDisposable).Dispose() - return Ok (channelId, txId) + match ValidateRemoteInitMsg peerNode.InitMsg with + | Error validationError -> + return Error (NodeOpenChannelError.InitMsgValidation validationError :> IErrorMsg) + | Ok _ -> + let! fundedChannelRes = FundedChannel.AcceptChannel peerNode self.Account + match fundedChannelRes with + | Error acceptChannelError -> + if acceptChannelError.PossibleBug then + let msg = + SPrintF2 + "error accepting channel from peer %s: %s" + (peerNode.NodeEndPoint.ToString()) + (acceptChannelError :> IErrorMsg).Message + Infrastructure.ReportWarningMessage msg + |> ignore + return Error <| (NodeAcceptChannelError.AcceptChannel acceptChannelError :> IErrorMsg) + | Ok fundedChannel -> + let channelId = fundedChannel.ChannelId + let txId = fundedChannel.FundingTxId + (fundedChannel :> IDisposable).Dispose() + return Ok (channelId, txId) } // use ReceiveLightningEvent instead @@ -681,6 +698,127 @@ type Node = | Server nodeServer -> nodeServer.Account :> IAccount + member self.CreateAnchorFeeBumpForForceClose + (channelId: ChannelIdentifier) + (commitmentTxString: string) + (password: string) + : Async> = + async { + let account = self.Account + let privateKey = Account.GetPrivateKey (account :?> NormalUtxoAccount) password + let nodeMasterPrivKey = + match self with + | Client nodeClient -> nodeClient.NodeMasterPrivKey + | Server nodeServer -> nodeServer.NodeMasterPrivKey + let currency = self.Account.Currency + let serializedChannel = self.ChannelStore.LoadChannel channelId + let! feeBumpTransaction = async { + let network = + UtxoCoin.Account.GetNetwork currency + let commitmentTx = + Transaction.Parse(commitmentTxString, network) + let channelPrivKeys = + let channelIndex = serializedChannel.ChannelIndex + nodeMasterPrivKey.ChannelPrivKeys channelIndex + let targetAddress = + let originAddress = self.Account.PublicAddress + BitcoinAddress.Create(originAddress, network) + let! feeRate = async { + let! feeEstimator = FeeEstimator.Create currency + return feeEstimator.FeeRatePerKw + } + let transactionBuilderResult = + ForceCloseFundsRecovery.tryClaimAnchorFromCommitmentTx + channelPrivKeys + serializedChannel.SavedChannelState.StaticChannelConfig + commitmentTx + match transactionBuilderResult with + | Error (LocalAnchorRecoveryError.InvalidCommitmentTx invalidCommitmentTxError) -> + return failwith ("invalid commitment tx for force-closing: " + invalidCommitmentTxError.Message) + | Error LocalAnchorRecoveryError.AnchorNotFound -> + self.ChannelStore.ArchiveChannel channelId + return Error <| ClosingBalanceBelowDustLimit + | Ok transactionBuilder -> + let job = + Account.GetElectrumScriptHashFromPublicAddress account.Currency account.PublicAddress + |> ElectrumClient.GetUnspentTransactionOutputs + let! utxos = Server.Query account.Currency (QuerySettings.Default ServerSelectionMode.Fast) job None + + if not (utxos.Any()) then + return raise InsufficientFunds + let possibleInputs = + seq { + for utxo in utxos do + yield { + TransactionId = utxo.TxHash + OutputIndex = utxo.TxPos + Value = utxo.Value + } + } + + // first ones are the smallest ones + let inputsOrderedByAmount = possibleInputs.OrderBy(fun utxo -> utxo.Value) |> List.ofSeq + + transactionBuilder.AddKeys privateKey |> ignore + transactionBuilder.SendAllRemaining targetAddress |> ignore + + let requiredParentTxFee = feeRate.AsNBitcoinFeeRate().GetFee commitmentTx + let actualParentTxFee = + serializedChannel.FundingScriptCoin() :> ICoin + |> Array.singleton + |> commitmentTx.GetFee + let deltaParentTxFee = requiredParentTxFee - actualParentTxFee + + let rec addUtxoForChildFee unusedUtxos = + async { + try + let fees = transactionBuilder.EstimateFees (feeRate.AsNBitcoinFeeRate()) + return fees, unusedUtxos + with + | :? NBitcoin.NotEnoughFundsException as _ex -> + match unusedUtxos with + | [] -> return raise InsufficientFunds + | head::tail -> + let! newInput = head |> ConvertToInputOutpointInfo account.Currency + let newCoin = newInput |> ConvertToICoin (account :?> IUtxoAccount) + transactionBuilder.AddCoin newCoin |> ignore + return! addUtxoForChildFee tail + } + + let rec addUtxosForParentFeeAndFinalize unusedUtxos = + async { + try + return transactionBuilder.BuildTransaction true + with + | :? NBitcoin.NotEnoughFundsException as _ex -> + match unusedUtxos with + | [] -> return raise InsufficientFunds + | head::tail -> + let! newInput = head |> ConvertToInputOutpointInfo account.Currency + let newCoin = newInput |> ConvertToICoin (account :?> IUtxoAccount) + transactionBuilder.AddCoin newCoin |> ignore + return! addUtxosForParentFeeAndFinalize tail + } + + let! childFee, unusedUtxos = addUtxoForChildFee inputsOrderedByAmount + transactionBuilder.SendFees (childFee + deltaParentTxFee) |> ignore + let! transaction = addUtxosForParentFeeAndFinalize unusedUtxos + + let feeBumpTransaction: FeeBumpTx = + { + ChannelId = channelId + Currency = currency + Fee = MinerFee ((childFee + deltaParentTxFee).Satoshi, DateTime.UtcNow, currency) + Tx = + { + NBitcoinTx = transaction + } + } + return Ok feeBumpTransaction + } + return feeBumpTransaction + } + member self.CreateRecoveryTxForLocalForceClose (channelId: ChannelIdentifier) (commitmentTxString: string) @@ -755,6 +893,7 @@ type Node = ForceCloseTxIdOpt = TransactionIdentifier.Parse forceCloseTxId |> Some + ClosingTimestampUtc = Some DateTime.UtcNow } self.ChannelStore.SaveChannel newSerializedChannel return Ok forceCloseTxId @@ -763,7 +902,6 @@ type Node = member self.CreateRecoveryTxForRemoteForceClose (channelId: ChannelIdentifier) (closingTx: ForceCloseTx) - (requiresCpfp: bool) : Async> = async { let nodeMasterPrivKey = @@ -799,15 +937,7 @@ type Node = return Error <| ClosingBalanceBelowDustLimit | Ok transactionBuilder -> transactionBuilder.SendAll targetAddress |> ignore - let fee = - if requiresCpfp then - FeeEstimator.EstimateCpfpFee - transactionBuilder - feeRate - closingTx.Tx.NBitcoinTx - (serializedChannel.FundingScriptCoin()) - else - transactionBuilder.EstimateFees (feeRate.AsNBitcoinFeeRate()) + let fee = transactionBuilder.EstimateFees (feeRate.AsNBitcoinFeeRate()) transactionBuilder.SendFees fee |> ignore let recoveryTransaction: RecoveryTx = { diff --git a/src/GWallet.Backend/UtxoCoin/Lightning/Settings.fs b/src/GWallet.Backend/UtxoCoin/Lightning/Settings.fs index 23bfbc89d..a84a0b1e4 100644 --- a/src/GWallet.Backend/UtxoCoin/Lightning/Settings.fs +++ b/src/GWallet.Backend/UtxoCoin/Lightning/Settings.fs @@ -5,6 +5,7 @@ open System open NBitcoin open DotNetLightning.Utils open DotNetLightning.Serialization +open DotNetLightning.Serialization.Msgs open DotNetLightning.Chain open DotNetLightning.Channel @@ -57,6 +58,8 @@ module Settings = let internal SupportedFeatures (currency: Currency) (fundingOpt: Option) = let featureBits = FeatureBits.Zero featureBits.SetFeature Feature.OptionDataLossProtect FeaturesSupport.Optional true + featureBits.SetFeature Feature.OptionStaticRemoteKey FeaturesSupport.Mandatory true + featureBits.SetFeature Feature.OptionAnchorZeroFeeHtlcTx FeaturesSupport.Mandatory true if currency = Currency.LTC then let featureType = match fundingOpt with diff --git a/src/GWallet.Backend/UtxoCoin/Lightning/Transactions.fs b/src/GWallet.Backend/UtxoCoin/Lightning/Transactions.fs index 88597f24b..cfd01b5fe 100644 --- a/src/GWallet.Backend/UtxoCoin/Lightning/Transactions.fs +++ b/src/GWallet.Backend/UtxoCoin/Lightning/Transactions.fs @@ -25,6 +25,14 @@ type MutualCloseCpfp = Fee: MinerFee } +type FeeBumpTx = + { + ChannelId: ChannelIdentifier + Currency: Currency + Tx: UtxoTransaction + Fee: MinerFee + } + type RecoveryTx = { ChannelId: ChannelIdentifier diff --git a/src/GWallet.Backend/UtxoCoin/Lightning/Validation.fs b/src/GWallet.Backend/UtxoCoin/Lightning/Validation.fs new file mode 100644 index 000000000..61201901d --- /dev/null +++ b/src/GWallet.Backend/UtxoCoin/Lightning/Validation.fs @@ -0,0 +1,27 @@ +namespace GWallet.Backend.UtxoCoin.Lightning + +open DotNetLightning.Serialization +open DotNetLightning.Serialization.Msgs +open ResultUtils.Portability + +open GWallet.Backend.FSharpUtil + +module Validation = + type internal InitMsgValidationError = + | NoAnchorSupport + interface IErrorMsg with + member self.Message = + match self with + | NoAnchorSupport -> + "no anchor channel support (CPFP not possible, so potential loss of funds with this node; rather open a channel with a better node)" + member __.ChannelBreakdown: bool = + false + + let internal ValidateRemoteInitMsg (remoteInit: InitMsg): Result = + let hasAmchorSupport = + remoteInit.Features.HasFeature Feature.OptionAnchorZeroFeeHtlcTx + + if not hasAmchorSupport then + Error InitMsgValidationError.NoAnchorSupport + else + Ok () diff --git a/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs b/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs index 5560a6e46..3da87cd5f 100644 --- a/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs +++ b/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs @@ -172,7 +172,7 @@ module Account = return None } - let private ConvertToICoin (account: IUtxoAccount) (inputOutpointInfo: TransactionInputOutpointInfo): ICoin = + let internal ConvertToICoin (account: IUtxoAccount) (inputOutpointInfo: TransactionInputOutpointInfo): ICoin = let txHash = uint256 inputOutpointInfo.TransactionHash let scriptPubKeyInBytes = NBitcoin.DataEncoders.Encoders.Hex.DecodeData inputOutpointInfo.DestinationInHex let scriptPubKey = Script(scriptPubKeyInBytes) @@ -216,7 +216,7 @@ module Account = Value: Int64; } - let private ConvertToInputOutpointInfo currency (utxo: UnspentTransactionOutputInfo) + let internal ConvertToInputOutpointInfo currency (utxo: UnspentTransactionOutputInfo) : Async = async { let job = ElectrumClient.GetBlockchainTransaction utxo.TransactionId diff --git a/src/GWallet.Backend/packages.config b/src/GWallet.Backend/packages.config index 965c7d1fd..6d3123ab2 100644 --- a/src/GWallet.Backend/packages.config +++ b/src/GWallet.Backend/packages.config @@ -1,7 +1,7 @@  - + diff --git a/src/GWallet.Frontend.Console/GWallet.Frontend.Console.fsproj b/src/GWallet.Frontend.Console/GWallet.Frontend.Console.fsproj index 83a3848ff..5d92be4db 100644 --- a/src/GWallet.Frontend.Console/GWallet.Frontend.Console.fsproj +++ b/src/GWallet.Frontend.Console/GWallet.Frontend.Console.fsproj @@ -96,7 +96,7 @@ - ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220204-0457-git-9c7a03c\lib\netstandard2.0\ResultUtils.dll + ..\..\packages\DotNetLightning.Kiss.1.1.2-date20220322-1031-git-5379324\lib\netstandard2.0\ResultUtils.dll diff --git a/src/GWallet.Frontend.Console/LayerTwo.fs b/src/GWallet.Frontend.Console/LayerTwo.fs index 84e256de2..42c2feeba 100644 --- a/src/GWallet.Frontend.Console/LayerTwo.fs +++ b/src/GWallet.Frontend.Console/LayerTwo.fs @@ -654,6 +654,8 @@ module LayerTwo = | None -> () } + let TimeBeforeCpfpSuggestion = TimeSpan.FromMinutes 15. + let private txRecoveryMsg = "A transaction must be sent to recover funds." let ClaimFundsIfTimelockExpired @@ -692,13 +694,74 @@ module LayerTwo = trySendRecoveryTx |> UserInteraction.TryWithPasswordAsync else - return seq { - yield! UserInteraction.DisplayLightningChannelStatus channelInfo - yield sprintf " channel force-closed" - yield sprintf - " waiting for %i more confirmations before funds are recovered" - remainingConfirmations - } + // only check for CPFP if there is 0 confirmations + if remainingConfirmations = locallyForceClosedData.ToSelfDelay then + let! isCpfpNeeded = + ChannelManager.IsCpfpNeededForFundingSpendingTx + channelStore + channelInfo.ChannelId + locallyForceClosedData.ForceCloseTxId + if isCpfpNeeded && locallyForceClosedData.ClosingTimestampUtc.Add(TimeBeforeCpfpSuggestion) < DateTime.UtcNow then + let msg = + sprintf + "Channel %s has been force-closed but the closure transaction didn't confirm yet. Do you want to increase the fee (via creation of child transaction, e.g. CPFP)?" + (ChannelId.ToString channelInfo.ChannelId) + if UserInteraction.AskYesNo msg then + let trySendAnchorCpfp (password: string) = + async { + let nodeClient = Lightning.Connection.StartClient channelStore password + let commitmentTx = channelStore.GetCommitmentTx channelInfo.ChannelId + try + let! feeBumpTxRes = (Node.Client nodeClient).CreateAnchorFeeBumpForForceClose channelInfo.ChannelId commitmentTx password + let feeBumpTx = UnwrapResult feeBumpTxRes "shouldn't happen because we don't force close a channel if our output is under the dust limit" + if UserInteraction.ConfirmTxFee feeBumpTx.Fee then + do! UtxoCoin.Account.BroadcastRawTransaction + feeBumpTx.Currency + (feeBumpTx.Tx.ToString()) + |> Async.Ignore + return seq { + yield! UserInteraction.DisplayLightningChannelStatus channelInfo + yield sprintf " channel force-closed" + yield sprintf " CPFP performed, waiting for %i more confirmations before funds are recovered" remainingConfirmations + } + else + return seq { + yield! UserInteraction.DisplayLightningChannelStatus channelInfo + yield sprintf " channel force-closed" + yield sprintf " waiting for %i more confirmations before funds are recovered" remainingConfirmations + } + with + | :? InsufficientFunds -> + return seq { + yield! UserInteraction.DisplayLightningChannelStatus channelInfo + yield sprintf " channel force-closed" + yield sprintf " CPFP failed due to insufficient funds in your wallet" + yield sprintf " waiting for %i more confirmations before funds are recovered" remainingConfirmations + } + } + return! + trySendAnchorCpfp + |> UserInteraction.TryWithPasswordAsync + else + return seq { + yield! UserInteraction.DisplayLightningChannelStatus channelInfo + yield sprintf " channel force-closed" + yield sprintf " waiting for %i more confirmations before funds are recovered" remainingConfirmations + } + else + return seq { + yield! UserInteraction.DisplayLightningChannelStatus channelInfo + yield sprintf " channel force-closed" + yield sprintf " waiting for %i more confirmations before funds are recovered" remainingConfirmations + } + else + return seq { + yield! UserInteraction.DisplayLightningChannelStatus channelInfo + yield sprintf " channel force-closed" + yield sprintf + " waiting for %i more confirmations before funds are recovered" + remainingConfirmations + } } let FindForceClose @@ -723,23 +786,79 @@ module LayerTwo = (closingTxHeightOpt: Option) : Async> = async { - Console.WriteLine(sprintf "Channel %s has been force-closed by the counterparty." (ChannelId.ToString channelInfo.ChannelId)) - Console.WriteLine txRecoveryMsg - let tryClaimFunds password = - async { - let nodeClient = Lightning.Connection.StartClient channelStore password - let sendTx = - if closingTxHeightOpt.IsNone then - Console.WriteLine "The remote party tried to perform a forced closing of the channel (probably because it couldn't contact your node) recently (or not so recently if your device has been offline for a while), but their transaction didn't confirm yet." - UserInteraction.AskYesNo "Do you want to send an extra transaction (increasing the fee) that helps the channel to get closed faster? If you don't, your funds in the channel will not be recovered yet." - else - true - if sendTx then + if closingTxHeightOpt.IsNone then + let! isCpfpNeeded = + ChannelManager.IsCpfpNeededForFundingSpendingTx + channelStore + channelInfo.ChannelId + closingTx.Tx.Id + // we can't check for ``LayerTwo.TimeBeforeCPFPSuggestion`` TimeSpan here because we don't know when remote party broadcasted their force close tx + if isCpfpNeeded then + if UserInteraction.AskYesNo "You can speed up confirmation by sending a new (child) transaction that would increase the overall fees (CPFP), do you wish to proceed?" then + let trySendAnchorCpfp (password: string) = + async { + let nodeClient = Lightning.Connection.StartClient channelStore password + let commitmentTx = channelStore.GetCommitmentTx channelInfo.ChannelId + try + let! feeBumpTxRes = (Node.Client nodeClient).CreateAnchorFeeBumpForForceClose channelInfo.ChannelId commitmentTx password + match feeBumpTxRes with + | Ok feeBumpTx -> + if UserInteraction.ConfirmTxFee feeBumpTx.Fee then + do! UtxoCoin.Account.BroadcastRawTransaction + feeBumpTx.Currency + (feeBumpTx.Tx.ToString()) + |> Async.Ignore + return seq { + yield! UserInteraction.DisplayLightningChannelStatus channelInfo + yield " channel closed by counterparty" + yield " CPFP performed, waiting for 1 confirmation before funds are recovered" + } + else + return seq { + yield! UserInteraction.DisplayLightningChannelStatus channelInfo + yield " channel closed by counterparty" + yield " waiting for 1 confirmation before funds are recovered" + } + | Error ClosingBalanceBelowDustLimit -> + return seq { + yield! UserInteraction.DisplayLightningChannelStatus channelInfo + yield " channel closed by counterparty" + yield " Local channel balance was too small (below the \"dust\" limit) so no CPFP were performed." + } + with + | :? InsufficientFunds -> + return seq { + yield! UserInteraction.DisplayLightningChannelStatus channelInfo + yield " channel force-closed" + yield " CPFP failed due to insufficient funds in your wallet" + yield " waiting for 1 confirmation before funds are recovered" + } + } + return! + trySendAnchorCpfp + |> UserInteraction.TryWithPasswordAsync + else + return seq { + yield! UserInteraction.DisplayLightningChannelStatus channelInfo + yield " channel closed by counterparty" + yield " wait for 1 confirmation to recover your funds" + } + else + return seq { + yield! UserInteraction.DisplayLightningChannelStatus channelInfo + yield " channel closed by counterparty" + yield " wait for 1 confirmation to recover your funds" + } + else + Console.WriteLine(sprintf "Channel %s has been force-closed by the counterparty." (ChannelId.ToString channelInfo.ChannelId)) + Console.WriteLine txRecoveryMsg + let tryClaimFunds password = + async { + let nodeClient = Lightning.Connection.StartClient channelStore password let! recoveryTxResult = (Node.Client nodeClient).CreateRecoveryTxForRemoteForceClose channelInfo.ChannelId closingTx - closingTxHeightOpt.IsNone match recoveryTxResult with | Ok recoveryTx -> if UserInteraction.ConfirmTxFee recoveryTx.Fee then @@ -764,19 +883,11 @@ module LayerTwo = yield " channel closed by counterparty" yield " Local channel balance was too small (below the \"dust\" limit) so no funds were recovered." } - else - return seq { - yield! UserInteraction.DisplayLightningChannelStatus channelInfo - yield " channel closed by counterparty" - yield " remote party's force-close transaction confirmation in progress" - } - } + } - return! UserInteraction.TryWithPasswordAsync tryClaimFunds + return! UserInteraction.TryWithPasswordAsync tryClaimFunds } - let TimeBeforeCpfpSuggestion = TimeSpan.FromMinutes 15. - let HandleMutualClose (channelStore: ChannelStore) (channelInfo: ChannelInfo) @@ -801,7 +912,7 @@ module LayerTwo = if isCpfpNeeded && closingTimestampUtc.Add(TimeBeforeCpfpSuggestion) < DateTime.UtcNow then let msg = sprintf - "Channel %s has been mutually-closed but the closure transaction didn't confirm yet. Do you want to increase the fee (via CPFP)?" + "Channel %s has been mutually-closed but the closure transaction didn't confirm yet. Do you want to increase the fee (via creation of child transaction, e.g. CPFP)?" (ChannelId.ToString channelInfo.ChannelId) let doCpfp = UserInteraction.AskYesNo msg if doCpfp then