Created
June 9, 2020 06:31
-
-
Save knocte/d30ca67e3b7fc32def1a85dbf43564d4 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
From 3caa604be0918f8b264984451348735f30faab2b Mon Sep 17 00:00:00 2001 | |
From: "Andres G. Aragoneses" <[email protected]> | |
Date: Mon, 8 Jun 2020 00:50:05 +0800 | |
Subject: [PATCH 1/3] Backend,Frontend.Console: improve B's API so that F.C.'s | |
doesn't need refs | |
Improve the API design of GWallet.Backend project so that | |
GWallet.Frontend.Console doesn't need to reference directly | |
some sub-dependencies of GWallet.Backend, such as NBitcoin | |
and DotNetLightning. | |
--- | |
src/GWallet.Backend/GWallet.Backend.fsproj | 1 + | |
src/GWallet.Backend/PublicKey.fs | 11 + | |
.../UtxoCoin/Lightning/Lightning.fs | 265 +++++++++++++----- | |
.../UtxoCoin/Lightning/SerializedChannel.fs | 21 +- | |
.../UtxoCoin/UtxoCoinAccount.fs | 12 +- | |
.../GWallet.Frontend.Console.fsproj | 12 - | |
src/GWallet.Frontend.Console/Program.fs | 76 ++--- | |
.../UserInteraction.fs | 16 +- | |
src/GWallet.Frontend.Console/packages.config | 2 - | |
9 files changed, 256 insertions(+), 160 deletions(-) | |
create mode 100644 src/GWallet.Backend/PublicKey.fs | |
diff --git a/src/GWallet.Backend/GWallet.Backend.fsproj b/src/GWallet.Backend/GWallet.Backend.fsproj | |
index 678a79fc..c841a96c 100644 | |
--- a/src/GWallet.Backend/GWallet.Backend.fsproj | |
+++ b/src/GWallet.Backend/GWallet.Backend.fsproj | |
@@ -61,6 +61,7 @@ <Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.mic | |
<Compile Include="Config.fs" /> | |
<Compile Include="Networking.fs" /> | |
<Compile Include="JsonRpcTcpClient.fs" /> | |
+ <Compile Include="PublicKey.fs" /> | |
<Compile Include="IBlockchainFeeInfo.fs" /> | |
<Compile Include="TransferAmount.fs" /> | |
<Compile Include="Infrastructure.fs" /> | |
diff --git a/src/GWallet.Backend/PublicKey.fs b/src/GWallet.Backend/PublicKey.fs | |
new file mode 100644 | |
index 00000000..74842efa | |
--- /dev/null | |
+++ b/src/GWallet.Backend/PublicKey.fs | |
@@ -0,0 +1,11 @@ | |
+ | |
+namespace GWallet.Backend | |
+ | |
+type PublicKey(pubKey: string, currency: Currency) = | |
+ do | |
+ if currency = Currency.BTC || currency = Currency.LTC then | |
+ NBitcoin.PubKey pubKey |> ignore | |
+ | |
+ override __.ToString() = | |
+ pubKey | |
+ | |
diff --git a/src/GWallet.Backend/UtxoCoin/Lightning/Lightning.fs b/src/GWallet.Backend/UtxoCoin/Lightning/Lightning.fs | |
index ee1a23ee..5f4d2785 100644 | |
--- a/src/GWallet.Backend/UtxoCoin/Lightning/Lightning.fs | |
+++ b/src/GWallet.Backend/UtxoCoin/Lightning/Lightning.fs | |
@@ -25,6 +25,45 @@ open FSharp.Core | |
module Lightning = | |
+ | |
+ type Connection = | |
+ { | |
+ Client: TcpClient | |
+ | |
+ // ideally we would mark the members below as 'internal' only, but: https://stackoverflow.com/q/62274013/544947 | |
+ Init: Init | |
+ Peer: Peer | |
+ } | |
+ | |
+ type IChannelToBeOpened = | |
+ abstract member ConfirmationsRequired: uint32 with get | |
+ | |
+ type FullChannel = | |
+ { | |
+ // ideally we would mark all the members as 'internal' only, but: https://stackoverflow.com/q/62274013/544947 | |
+ InboundChannel: AcceptChannel | |
+ OutboundChannel: Channel | |
+ Peer: Peer | |
+ } | |
+ interface IChannelToBeOpened with | |
+ member self.ConfirmationsRequired | |
+ with get(): uint32 = | |
+ self.InboundChannel.MinimumDepth.Value | |
+ | |
+ type PotentialChannel internal (channelKeysSeed, temporaryChannelId) = | |
+ member val internal KeysSeed = channelKeysSeed with get | |
+ member val internal TemporaryId = temporaryChannelId with get | |
+ | |
+ type ChannelCreationDetails = | |
+ { | |
+ Client: TcpClient | |
+ Password: Ref<string> | |
+ | |
+ // ideally we would mark the members below as 'internal' only, but: https://stackoverflow.com/q/62274013/544947 | |
+ ChannelInfo: PotentialChannel | |
+ FullChannel: FullChannel | |
+ } | |
+ | |
let private hex = DataEncoders.HexEncoder() | |
let GetIndexOfDestinationInOutputSeq (dest: IDestination) (outputs: seq<IndexedTxOut>): TxOutIndex = | |
@@ -56,7 +95,7 @@ module Lightning = | |
During: string | |
} | |
- type LNError = | |
+ type internal LNInternalError = | |
| DNLError of ErrorMessage | |
| ConnectError of seq<SocketException> | |
| StringError of StringErrorInner | |
@@ -94,10 +133,13 @@ module Lightning = | |
else | |
"Error: peer disconnected for unknown reason" | |
+ type LNError internal (error: LNInternalError) = | |
+ member val internal Inner = error with get | |
+ member val Message = error.Message with get | |
let internal ReadExactAsync (stream: NetworkStream) | |
(numberBytesToRead: int) | |
- : Async<Result<array<byte>, LNError>> = | |
+ : Async<Result<array<byte>, LNInternalError>> = | |
let buf: array<byte> = Array.zeroCreate numberBytesToRead | |
let rec read buf totalBytesRead = async { | |
let! bytesRead = | |
@@ -117,7 +159,7 @@ module Lightning = | |
} | |
read buf 0 | |
- let ReadAsync (keyRepo: DefaultKeyRepository) (peer: Peer) (stream: NetworkStream): Async<Result<PeerCommand, LNError>> = | |
+ let internal ReadAsync (keyRepo: DefaultKeyRepository) (peer: Peer) (stream: NetworkStream): Async<Result<PeerCommand, LNInternalError>> = | |
match peer.ChannelEncryptor.GetNoiseStep() with | |
| ActTwo -> | |
async { | |
@@ -216,8 +258,12 @@ module Lightning = | |
let channelConfig = CreateChannelConfig account | |
Channel.CreateCurried channelConfig | |
- type ChannelEnvironment = { Account: UtxoCoin.NormalUtxoAccount; NodeIdForResponder: NodeId; KeyRepo: DefaultKeyRepository } | |
- type Connection = { Init: Init; Peer: Peer; Client: TcpClient } | |
+ type ChannelEnvironment internal (account: UtxoCoin.NormalUtxoAccount, | |
+ nodeIdForResponder: NodeId, | |
+ keyRepo: DefaultKeyRepository) = | |
+ member val internal Account = account with get | |
+ member val internal NodeIdForResponder = nodeIdForResponder with get | |
+ member val internal KeyRepo = keyRepo with get | |
let internal Send (msg: ILightningMsg) (peer: Peer) (stream: NetworkStream): Async<Peer> = | |
async { | |
@@ -242,10 +288,12 @@ module Lightning = | |
context | |
Infrastructure.ReportWarningMessage msg | |
- let ConnectAndHandshake ({ Account = _; NodeIdForResponder = nodeIdForResponder; KeyRepo = keyRepo }: ChannelEnvironment) | |
+ let ConnectAndHandshake (channelEnv: ChannelEnvironment) | |
(channelCounterpartyIP: IPEndPoint) | |
: Async<Result<Connection, LNError>> = | |
async { | |
+ let nodeIdForResponder = channelEnv.NodeIdForResponder | |
+ let keyRepo = channelEnv.KeyRepo | |
let responderId = channelCounterpartyIP :> EndPoint |> PeerId | |
let initialPeer = Peer.CreateOutbound(responderId, nodeIdForResponder, keyRepo.NodeSecret.PrivateKey) | |
let act1, peerEncryptor = PeerChannelEncryptor.getActOne initialPeer.ChannelEncryptor | |
@@ -264,7 +312,7 @@ module Lightning = | |
return Error <| ConnectError socketExceptions | |
} | |
match connectRes with | |
- | Error err -> return Error err | |
+ | Error err -> return Error <| LNError err | |
| Ok () -> | |
let stream = client.GetStream() | |
do! stream.WriteAsync(act1, 0, act1.Length) |> Async.AwaitTask | |
@@ -275,8 +323,8 @@ module Lightning = | |
match act2Res with | |
| Error (PeerDisconnected abruptly) -> | |
ReportDisconnection channelCounterpartyIP nodeIdForResponder abruptly "receiving act 2" | |
- return Error (PeerDisconnected abruptly) | |
- | Error err -> return Error err | |
+ return Error <| LNError (PeerDisconnected abruptly) | |
+ | Error err -> return Error <| LNError err | |
| Ok act2 -> | |
let actThree, receivedAct2Peer = | |
match Peer.executeCommand sentAct1Peer act2 with | |
@@ -300,21 +348,22 @@ module Lightning = | |
| Error (PeerDisconnected abruptly) -> | |
let context = SPrintF1 "receiving init message while connecting, our init: %A" plainInit | |
ReportDisconnection channelCounterpartyIP nodeIdForResponder abruptly context | |
- return Error (PeerDisconnected abruptly) | |
- | Error err -> return Error err | |
+ return Error <| LNError (PeerDisconnected abruptly) | |
+ | Error err -> return Error <| LNError err | |
| Ok init -> | |
return | |
match Peer.executeCommand sentInitPeer init with | |
| Ok (ReceivedInit (newInit, _) as evt::[]) -> | |
let peer = Peer.applyEvent sentInitPeer evt | |
- Ok { Init = newInit; Peer = peer; Client = client } | |
+ Ok <| { Init = newInit; Peer = peer; Client = client } | |
| Ok _ -> | |
failwith "not one good ReceivedInit event" | |
| Error peerError -> | |
failwith <| SPrintF1 "couldn't parse init: %s" peerError.Message | |
} | |
- let rec ReadUntilChannelMessage (keyRepo: DefaultKeyRepository, peer: Peer, stream: NetworkStream): Async<Result<Peer * IChannelMsg, LNError>> = | |
+ let rec internal ReadUntilChannelMessage (keyRepo: DefaultKeyRepository, peer: Peer, stream: NetworkStream) | |
+ : Async<Result<Peer * IChannelMsg, LNInternalError>> = | |
async { | |
let! channelMsgRes = ReadAsync keyRepo peer stream | |
match channelMsgRes with | |
@@ -356,14 +405,18 @@ module Lightning = | |
DefaultFinalScriptPubKey = account |> CreatePayoutScript | |
} | |
- let GetAcceptChannel ({ Account = account; NodeIdForResponder = nodeIdForResponder; KeyRepo = keyRepo }: ChannelEnvironment) | |
- ({ Init = receivedInit; Peer = receivedInitPeer; Client = client }: Connection) | |
+ let GetAcceptChannel (potentialChannel: PotentialChannel) | |
+ (channelEnv: ChannelEnvironment) | |
+ (connection: Connection) | |
(channelCapacity: TransferAmount) | |
(metadata: TransactionMetadata) | |
(password: unit -> string) | |
(balance: decimal) | |
- (temporaryChannelId: ChannelId) | |
- : Async<Result<AcceptChannel * Channel * Peer, LNError>> = | |
+ : Async<Result<FullChannel, LNError>> = | |
+ let client = connection.Client | |
+ let receivedInit = connection.Init | |
+ let receivedInitPeer = connection.Peer | |
+ let account = channelEnv.Account | |
let fundingTxProvider (dest: IDestination, amount: Money, _: FeeRatePerKw) = | |
let transferAmount = TransferAmount (amount.ToDecimal MoneyUnit.BTC, balance, Currency.BTC) | |
Debug.Assert ( | |
@@ -378,6 +431,8 @@ module Lightning = | |
(fundingTransaction |> FinalizedTx, GetIndexOfDestinationInOutputSeq dest outputs) |> Ok | |
let fundingAmount = Money (channelCapacity.ValueToSend, MoneyUnit.BTC) | |
+ let nodeIdForResponder = channelEnv.NodeIdForResponder | |
+ let keyRepo = channelEnv.KeyRepo | |
let channelKeys, localParams = GetLocalParams true fundingAmount nodeIdForResponder account keyRepo | |
async { | |
@@ -386,7 +441,7 @@ module Lightning = | |
let initFunder = | |
{ | |
InputInitFunder.PushMSat = LNMoney.MilliSatoshis 0L | |
- TemporaryChannelId = temporaryChannelId | |
+ TemporaryChannelId = potentialChannel.TemporaryId | |
FundingSatoshis = fundingAmount | |
InitFeeRatePerKw = feeEstimator.GetEstSatPer1000Weight <| ConfirmationTarget.Normal | |
FundingTxFeeRatePerKw = feeEstimator.GetEstSatPer1000Weight <| ConfirmationTarget.Normal | |
@@ -420,29 +475,33 @@ module Lightning = | |
| Error (PeerDisconnected abruptly) -> | |
let context = SPrintF1 "receiving accept_channel, our open_channel == %A" openChanMsg | |
ReportDisconnection channelCounterpartyIP nodeIdForResponder abruptly context | |
- return Error (PeerDisconnected abruptly) | |
- | Error errorMsg -> return Error errorMsg | |
+ return Error <| LNError (PeerDisconnected abruptly) | |
+ | Error err -> return Error <| LNError err | |
| Ok (receivedOpenChanReplyPeer, chanMsg) -> | |
match chanMsg with | |
| :? AcceptChannel as acceptChannel -> | |
- return Ok (acceptChannel, sentOpenChan, receivedOpenChanReplyPeer) | |
+ return Ok ({ | |
+ InboundChannel = acceptChannel | |
+ OutboundChannel = sentOpenChan | |
+ Peer = receivedOpenChanReplyPeer | |
+ }) | |
| _ -> | |
- return Error <| StringError { | |
+ return Error <| LNError (StringError { | |
Msg = SPrintF1 "channel message is not accept channel: %s" (chanMsg.GetType().Name) | |
During = "waiting for accept_channel" | |
- } | |
+ }) | |
| Ok evtList -> | |
return failwith <| SPrintF1 "event was not a single NewOutboundChannelStarted, it was: %A" evtList | |
| Error channelError -> | |
- return Error <| DNLChannelError channelError | |
+ return Error <| LNError (DNLChannelError channelError) | |
} | |
- let ContinueFromAcceptChannel (keyRepo: DefaultKeyRepository) | |
- (acceptChannel: AcceptChannel) | |
- (sentOpenChan: Channel) | |
- (stream: NetworkStream) | |
- (receivedOpenChanReplyPeer: Peer) | |
- : Async<Result<string * Channel, LNError>> = | |
+ let internal ContinueFromAcceptChannel (keyRepo: DefaultKeyRepository) | |
+ (acceptChannel: AcceptChannel) | |
+ (sentOpenChan: Channel) | |
+ (stream: NetworkStream) | |
+ (receivedOpenChanReplyPeer: Peer) | |
+ : Async<Result<string * Channel, LNInternalError>> = | |
async { | |
match Channel.executeCommand sentOpenChan (ApplyAcceptChannel acceptChannel) with | |
| Ok (ChannelEvent.WeAcceptedAcceptChannel(fundingCreated, _) as evt::[]) -> | |
@@ -458,9 +517,9 @@ module Lightning = | |
| Error (PeerDisconnected abruptly) -> | |
let context = SPrintF1 "receiving funding_created, their accept_channel == %A" acceptChannel | |
ReportDisconnection channelCounterpartyIP nodeIdForResponder abruptly context | |
- return Error (PeerDisconnected abruptly) | |
- | Error errorMsg -> | |
- return Error errorMsg | |
+ return Error <| PeerDisconnected abruptly | |
+ | Error error -> | |
+ return Error error | |
| Ok (_, chanMsg) -> | |
match chanMsg with | |
| :? FundingSigned as fundingSigned -> | |
@@ -503,7 +562,7 @@ module Lightning = | |
return Error <| StringError innerError | |
} | |
- let GetSeedAndRepo (random: Random): uint256 * DefaultKeyRepository * ChannelId = | |
+ let private GetSeedAndRepo (random: Random): uint256 * DefaultKeyRepository * ChannelId = | |
let channelKeysSeedBytes = Array.zeroCreate 32 | |
random.NextBytes channelKeysSeedBytes | |
let channelKeysSeed = uint256 channelKeysSeedBytes | |
@@ -516,6 +575,12 @@ module Lightning = | |
|> ChannelId | |
channelKeysSeed, keyRepo, temporaryChannelId | |
+ let GenerateNewPotentialChannelDetails account (channelCounterpartyPubKey: PublicKey) (random: Random) = | |
+ let channelKeysSeed, keyRepo, temporaryChannelId = GetSeedAndRepo random | |
+ let pubKey = NBitcoin.PubKey (channelCounterpartyPubKey.ToString()) | |
+ let channelEnv = ChannelEnvironment(account, NodeId pubKey, keyRepo) | |
+ PotentialChannel(channelKeysSeed, temporaryChannelId), channelEnv | |
+ | |
let GetNewChannelFilename(): string = | |
SerializedChannel.ChannelFilePrefix | |
// this offset is the approximate time this feature was added (making filenames shorter) | |
@@ -523,21 +588,28 @@ module Lightning = | |
+ SerializedChannel.ChannelFileEnding | |
let ContinueFromAcceptChannelAndSave (account: UtxoCoin.NormalUtxoAccount) | |
- (channelKeysSeed: uint256) | |
(channelCounterpartyIP: IPEndPoint) | |
- (acceptChannel: AcceptChannel) | |
- (chan: Channel) | |
- (stream: NetworkStream) | |
- (peer: Peer) | |
+ (channelDetails: ChannelCreationDetails) | |
: Async<Result<string, LNError>> = // TxId of Funding Transaction is returned | |
async { | |
- let keyRepo = SerializedChannel.UIntToKeyRepo channelKeysSeed | |
- let! res = ContinueFromAcceptChannel keyRepo acceptChannel chan stream peer | |
+ let stream = channelDetails.Client.GetStream() | |
+ let keyRepo = SerializedChannel.UIntToKeyRepo channelDetails.ChannelInfo.KeysSeed | |
+ let! res = | |
+ ContinueFromAcceptChannel keyRepo | |
+ channelDetails.FullChannel.InboundChannel | |
+ channelDetails.FullChannel.OutboundChannel | |
+ stream | |
+ channelDetails.FullChannel.Peer | |
match res with | |
- | Error errorMsg -> return Error errorMsg | |
+ | Error error -> return Error <| LNError error | |
| Ok (fundingTxId, receivedFundingSignedChan) -> | |
let fileName = GetNewChannelFilename() | |
- SerializedChannel.Save account receivedFundingSignedChan channelKeysSeed channelCounterpartyIP acceptChannel.MinimumDepth fileName | |
+ SerializedChannel.Save account | |
+ receivedFundingSignedChan | |
+ channelDetails.ChannelInfo.KeysSeed | |
+ channelCounterpartyIP | |
+ channelDetails.FullChannel.InboundChannel.MinimumDepth | |
+ fileName | |
Infrastructure.LogDebug <| SPrintF1 "Channel saved to %s" fileName | |
return Ok fundingTxId | |
} | |
@@ -568,8 +640,8 @@ module Lightning = | |
} | |
type NotReadyReason = { | |
- CurrentConfirmations: BlockHeightOffset32 | |
- NeededConfirmations: BlockHeightOffset32 | |
+ CurrentConfirmations: uint32 | |
+ NeededConfirmations: uint32 | |
} | |
type internal ChannelMessageOrDeepEnough = | |
@@ -601,8 +673,8 @@ module Lightning = | |
} | |
else | |
NotReady { | |
- CurrentConfirmations = details.ConfirmationsCount | |
- NeededConfirmations = details.SerializedChannel.MinSafeDepth | |
+ CurrentConfirmations = details.ConfirmationsCount.Value | |
+ NeededConfirmations = details.SerializedChannel.MinSafeDepth.Value | |
} | |
let internal GetFundingLockedMsg (channel: Channel) (channelCommand: ChannelCommand): Channel * FundingLocked = | |
@@ -699,7 +771,7 @@ module Lightning = | |
let! channelCommand = channelCommandAction | |
let keyRepo = SerializedChannel.UIntToKeyRepo channelKeysSeed | |
let channelEnvironment: ChannelEnvironment = | |
- { Account = details.Account; NodeIdForResponder = notReestablishedChannel.RemoteNodeId; KeyRepo = keyRepo } | |
+ ChannelEnvironment(details.Account, notReestablishedChannel.RemoteNodeId, keyRepo) | |
let! connectionRes = ConnectAndHandshake channelEnvironment channelCounterpartyIP | |
match connectionRes with | |
| Error err -> return Error err | |
@@ -726,8 +798,8 @@ module Lightning = | |
match msgRes with | |
| Error (PeerDisconnected abruptly) -> | |
ReportDisconnection channelCounterpartyIP nodeIdForResponder abruptly "receiving channel_reestablish or funding_locked" | |
- return Error (PeerDisconnected abruptly) | |
- | Error errorMsg -> return Error errorMsg | |
+ return Error <| LNError (PeerDisconnected abruptly) | |
+ | Error error -> return Error <| LNError error | |
| Ok (receivedChannelReestablishPeer, chanMsg) -> | |
let! fundingLockedRes = | |
match chanMsg with | |
@@ -762,7 +834,7 @@ module Lightning = | |
return Error <| StringError { Msg = msg; During = "reception of reply to channel_reestablish" } | |
} | |
match fundingLockedRes with | |
- | Error errorMsg -> return Error errorMsg | |
+ | Error error -> return Error <| LNError error | |
| Ok fundingLocked -> | |
match Channel.executeCommand channelWithFundingLockedSent (ApplyFundingLocked fundingLocked) with | |
| Ok ((ChannelEvent.BothFundingLocked _) as evt::[]) -> | |
@@ -776,13 +848,19 @@ module Lightning = | |
connection.Client.Dispose() | |
return Ok (UsableChannel txIdHex) | |
| Error channelError -> | |
- return Error <| DNLChannelError channelError | |
+ return Error <| LNError (DNLChannelError channelError) | |
| Ok (evt::[]) -> | |
let msg = SPrintF1 "expected event BothFundingLocked, is %s" (evt.GetType().Name) | |
- return Error <| StringError { Msg = msg; During = "application of funding_locked" } | |
+ return Error <| LNError (StringError { | |
+ Msg = msg | |
+ During = "application of funding_locked" | |
+ }) | |
| Ok _ -> | |
let msg = "expected only one event" | |
- return Error <| StringError { Msg = msg; During = "application of funding_locked" } | |
+ return Error <| LNError (StringError { | |
+ Msg = msg | |
+ During = "application of funding_locked" | |
+ }) | |
} | |
let AcceptTheirChannel (random: Random) | |
@@ -805,24 +883,29 @@ module Lightning = | |
let! act1Res = ReadExactAsync stream bolt08ActOneLength | |
match act1Res with | |
- | Error err -> return Error err | |
+ | Error err -> return Error <| LNError err | |
| Ok act1 -> | |
let act1Result = PeerChannelEncryptor.processActOneWithKey act1 ourNodeSecret initialPeer.ChannelEncryptor | |
match act1Result with | |
| Error err -> | |
- return Error <| StringError { Msg = SPrintF1 "error from DNL: %A" err; During = "processing of their act1" } | |
+ return Error <| LNError(StringError { | |
+ Msg = SPrintF1 "error from DNL: %A" err | |
+ During = "processing of their act1" | |
+ }) | |
| Ok (act2, pce) -> | |
let act2, peerWithSentAct2 = | |
act2, { initialPeer with ChannelEncryptor = pce } | |
do! stream.WriteAsync(act2, 0, act2.Length) |> Async.AwaitTask | |
let! act3Res = ReadExactAsync stream bolt08ActThreeLength | |
match act3Res with | |
- | Error err -> return Error err | |
+ | Error err -> return Error <| LNError err | |
| Ok act3 -> | |
let act3Result = PeerChannelEncryptor.processActThree act3 peerWithSentAct2.ChannelEncryptor | |
match act3Result with | |
| Error err -> | |
- return Error <| StringError { Msg = SPrintF1 "error from DNL: %A" err; During = "processing of their act3" } | |
+ return Error <| LNError(StringError { | |
+ Msg = SPrintF1 "error from DNL: %A" err; During = "processing of their act3" | |
+ }) | |
| Ok (remoteNodeId, pce) -> | |
let receivedAct3Peer = { peerWithSentAct2 with ChannelEncryptor = pce } | |
Infrastructure.LogDebug "Receiving init..." | |
@@ -830,24 +913,26 @@ module Lightning = | |
match initRes with | |
| Error (PeerDisconnected abruptly) -> | |
ReportDisconnection channelCounterpartyIP remoteNodeId abruptly "receiving init message while accepting" | |
- return Error (PeerDisconnected abruptly) | |
- | Error err -> return Error err | |
+ return Error <| LNError (PeerDisconnected abruptly) | |
+ | Error err -> return Error <| LNError err | |
| Ok init -> | |
match Peer.executeCommand receivedAct3Peer init with | |
| Error peerError -> | |
- return Error <| StringError { Msg = SPrintF1 "couldn't parse init: %s" peerError.Message; During = "receiving init" } | |
+ return Error <| LNError(StringError { | |
+ Msg = SPrintF1 "couldn't parse init: %s" peerError.Message | |
+ During = "receiving init" | |
+ }) | |
| Ok (ReceivedInit (newInit, _) as evt::[]) -> | |
let peer = Peer.applyEvent receivedAct3Peer evt | |
- let connection: Connection = | |
- { Init = newInit; Peer = peer; Client = client } | |
+ let connection = { Init = newInit; Peer = peer; Client = client } | |
let! sentInitPeer = Send plainInit connection.Peer stream | |
Infrastructure.LogDebug "Receiving open_channel..." | |
let! msgRes = ReadUntilChannelMessage (keyRepo, sentInitPeer, stream) | |
match msgRes with | |
| Error (PeerDisconnected abruptly) -> | |
ReportDisconnection channelCounterpartyIP remoteNodeId abruptly "receiving open_channel" | |
- return Error (PeerDisconnected abruptly) | |
- | Error errorMsg -> return Error errorMsg | |
+ return Error <| LNError (PeerDisconnected abruptly) | |
+ | Error error -> return Error <| LNError error | |
| Ok (receivedOpenChanPeer, chanMsg) -> | |
match chanMsg with | |
| :? OpenChannel as openChannel -> | |
@@ -895,8 +980,8 @@ module Lightning = | |
| Error (PeerDisconnected abruptly) -> | |
let context = SPrintF2 "receiving funding_created, their open_channel == %A, our accept_channel == %A" openChannel acceptChannel | |
ReportDisconnection channelCounterpartyIP remoteNodeId abruptly context | |
- return Error (PeerDisconnected abruptly) | |
- | Error errorMsg -> return Error errorMsg | |
+ return Error <| LNError (PeerDisconnected abruptly) | |
+ | Error error -> return Error <| LNError error | |
| Ok (receivedFundingCreatedPeer, chanMsg) -> | |
match chanMsg with | |
| :? FundingCreated as fundingCreated -> | |
@@ -918,25 +1003,51 @@ module Lightning = | |
return Ok () | |
| Ok evtList -> | |
- return Error <| StringError { Msg = SPrintF1 "event was not a single WeAcceptedFundingCreated, it was: %A" evtList; During = "application of their funding_created message" } | |
+ return Error <| LNError (StringError { | |
+ Msg = SPrintF1 "event was not a single WeAcceptedFundingCreated, it was: %A" evtList | |
+ During = "application of their funding_created message" | |
+ }) | |
| Error channelError -> | |
- return Error <| StringError { Msg = SPrintF1 "could not apply funding_created: %s" channelError.Message; During = "application of their funding_created message" } | |
+ return Error <| LNError (StringError { | |
+ Msg = SPrintF1 "could not apply funding_created: %s" channelError.Message | |
+ During = "application of their funding_created message" | |
+ }) | |
| _ -> | |
- return Error <| StringError { Msg = SPrintF1 "channel message is not funding_created: %s" (chanMsg.GetType().Name); During = "reception of answer to accept_channel" } | |
+ return Error <| LNError (StringError { | |
+ Msg = SPrintF1 "channel message is not funding_created: %s" (chanMsg.GetType().Name) | |
+ During = "reception of answer to accept_channel" | |
+ }) | |
| Ok evtList -> | |
- return Error <| StringError { Msg = SPrintF1 "event list was not a single WeAcceptedOpenChannel, it was: %A" evtList; During = "generation of an accept_channel message" } | |
+ return Error <| LNError (StringError { | |
+ Msg = SPrintF1 "event list was not a single WeAcceptedOpenChannel, it was: %A" evtList | |
+ During = "generation of an accept_channel message" | |
+ }) | |
| Error err -> | |
- return Error <| StringError { Msg = SPrintF1 "error from DNL: %A" err; During = "generation of an accept_channel message" } | |
+ return Error <| LNError (StringError { | |
+ Msg = SPrintF1 "error from DNL: %A" err | |
+ During = "generation of an accept_channel message" | |
+ }) | |
| Ok evtList -> | |
- return Error <| StringError { Msg = SPrintF1 "event was not a single NewInboundChannelStarted, it was: %A" evtList; During = "execution of CreateChannel command" } | |
+ return Error <| LNError (StringError { | |
+ Msg = SPrintF1 "event was not a single NewInboundChannelStarted, it was: %A" evtList | |
+ During = "execution of CreateChannel command" | |
+ }) | |
| Error channelError -> | |
- return Error <| StringError { Msg = SPrintF1 "could not execute channel command: %s" channelError.Message; During = "execution of CreateChannel command" } | |
+ return Error <| LNError (StringError { | |
+ Msg = SPrintF1 "could not execute channel command: %s" channelError.Message | |
+ During = "execution of CreateChannel command" | |
+ }) | |
| _ -> | |
- return Error <| StringError { Msg = SPrintF1 "channel message is not open_channel: %s" (chanMsg.GetType().Name); During = "reception of open_channel" } | |
+ return Error <| LNError (StringError { | |
+ Msg = SPrintF1 "channel message is not open_channel: %s" (chanMsg.GetType().Name) | |
+ During = "reception of open_channel" | |
+ }) | |
| Ok _ -> | |
- return Error <| StringError { Msg = "not one good ReceivedInit event"; During = "reception of init message" } | |
- | |
+ return Error <| LNError(StringError { | |
+ Msg = "not one good ReceivedInit event" | |
+ During = "reception of init message" | |
+ }) | |
} | |
diff --git a/src/GWallet.Backend/UtxoCoin/Lightning/SerializedChannel.fs b/src/GWallet.Backend/UtxoCoin/Lightning/SerializedChannel.fs | |
index a5c33a44..7de57e89 100644 | |
--- a/src/GWallet.Backend/UtxoCoin/Lightning/SerializedChannel.fs | |
+++ b/src/GWallet.Backend/UtxoCoin/Lightning/SerializedChannel.fs | |
@@ -144,15 +144,6 @@ type SerializedChannel = { | |
settings.Converters.Add commitmentsConverter | |
settings | |
- static member ListSavedChannels (): seq<string * int> = | |
- if SerializedChannel.LightningDir.Exists then | |
- let files = | |
- Directory.GetFiles | |
- ((SerializedChannel.LightningDir.ToString()), SerializedChannel.ChannelFilePrefix + "*" + SerializedChannel.ChannelFileEnding) | |
- files |> Seq.choose SerializedChannel.ExtractChannelNumber | |
- else | |
- Seq.empty | |
- | |
member this.SaveSerializedChannel (fileName: string) = | |
let json = Marshalling.SerializeCustom(this, SerializedChannel.LightningSerializerSettings) | |
let filePath = Path.Combine (SerializedChannel.LightningDir.FullName, fileName) | |
@@ -197,3 +188,15 @@ type SerializedChannel = { | |
(NodeId this.RemoteNodeId) | |
with State = this.ChanState | |
} | |
+ | |
+module ChannelManager = | |
+ | |
+ let ListSavedChannels (): seq<string * int> = | |
+ if SerializedChannel.LightningDir.Exists then | |
+ let files = | |
+ Directory.GetFiles | |
+ ((SerializedChannel.LightningDir.ToString()), | |
+ SerializedChannel.ChannelFilePrefix + "*" + SerializedChannel.ChannelFileEnding) | |
+ files |> Seq.choose SerializedChannel.ExtractChannelNumber | |
+ else | |
+ Seq.empty | |
diff --git a/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs b/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs | |
index 0561906f..2385fcd0 100644 | |
--- a/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs | |
+++ b/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs | |
@@ -229,8 +229,8 @@ module Account = | |
return! EstimateFees newTxBuilder feeRate account newInputs tail | |
} | |
- let EstimateFeeForDestination (account: IUtxoAccount) (amount: TransferAmount) (destination: IDestination) | |
- : Async<TransactionMetadata> = async { | |
+ let private EstimateFeeForDestination (account: IUtxoAccount) (amount: TransferAmount) (destination: IDestination) | |
+ : Async<TransactionMetadata> = async { | |
let rec addInputsUntilAmount (utxos: List<UnspentTransactionOutputInfo>) | |
soFarInSatoshis | |
amount | |
@@ -322,6 +322,14 @@ module Account = | |
return raise <| InsufficientBalanceForFee None | |
} | |
+ // move to lightning module? | |
+ let EstimateChannelOpeningFee (account: IUtxoAccount) (amount: TransferAmount) = | |
+ let witScriptIdLength = 32 | |
+ // this dummy address is only used for fee estimation | |
+ let nullScriptId = NBitcoin.WitScriptId (Array.zeroCreate witScriptIdLength) | |
+ // TODO: pass currency argument in order to support Litecoin+Lightning here | |
+ let dummyAddr = NBitcoin.BitcoinWitScriptAddress (nullScriptId, Config.BitcoinNet) | |
+ EstimateFeeForDestination account amount dummyAddr | |
let EstimateFee (account: IUtxoAccount) (amount: TransferAmount) (destination: string) | |
: Async<TransactionMetadata> = | |
diff --git a/src/GWallet.Frontend.Console/GWallet.Frontend.Console.fsproj b/src/GWallet.Frontend.Console/GWallet.Frontend.Console.fsproj | |
index c3a18875..a23bfa58 100644 | |
--- a/src/GWallet.Frontend.Console/GWallet.Frontend.Console.fsproj | |
+++ b/src/GWallet.Frontend.Console/GWallet.Frontend.Console.fsproj | |
@@ -94,9 +94,6 @@ <?xml version="1.0" encoding="utf-8"?> | |
<Reference Include="Microsoft.Extensions.Logging.Abstractions"> | |
<HintPath>..\..\packages\Microsoft.Extensions.Logging.Abstractions.1.0.0\lib\netstandard1.1\Microsoft.Extensions.Logging.Abstractions.dll</HintPath> | |
</Reference> | |
- <Reference Include="NBitcoin"> | |
- <HintPath>..\..\packages\NBitcoin.5.0.13\lib\net461\NBitcoin.dll</HintPath> | |
- </Reference> | |
<Reference Include="System.Net.Http" /> | |
<Reference Include="BouncyCastle.Crypto"> | |
<HintPath>..\..\packages\Portable.BouncyCastle.1.8.6\lib\net40\BouncyCastle.Crypto.dll</HintPath> | |
@@ -104,15 +101,6 @@ <?xml version="1.0" encoding="utf-8"?> | |
<Reference Include="System.Numerics.Vectors"> | |
<HintPath>..\..\packages\System.Numerics.Vectors.4.4.0\lib\net46\System.Numerics.Vectors.dll</HintPath> | |
</Reference> | |
- <Reference Include="DotNetLightning.Core"> | |
- <HintPath>..\..\packages\DotNetLightning.1.1.2-date20200527-1047-git-42c7cb9\lib\netstandard2.0\DotNetLightning.Core.dll</HintPath> | |
- </Reference> | |
- <Reference Include="InternalBech32Encoder"> | |
- <HintPath>..\..\packages\DotNetLightning.1.1.2-date20200527-1047-git-42c7cb9\lib\netstandard2.0\InternalBech32Encoder.dll</HintPath> | |
- </Reference> | |
- <Reference Include="ResultUtils"> | |
- <HintPath>..\..\packages\DotNetLightning.1.1.2-date20200527-1047-git-42c7cb9\lib\netstandard2.0\ResultUtils.dll</HintPath> | |
- </Reference> | |
<Reference Include="System.Runtime"> | |
<HintPath>..\..\packages\System.Runtime.4.1.0\lib\net462\System.Runtime.dll</HintPath> | |
</Reference> | |
diff --git a/src/GWallet.Frontend.Console/Program.fs b/src/GWallet.Frontend.Console/Program.fs | |
index ffac8ff2..98fd7ad6 100644 | |
--- a/src/GWallet.Frontend.Console/Program.fs | |
+++ b/src/GWallet.Frontend.Console/Program.fs | |
@@ -6,11 +6,11 @@ open System.Text.RegularExpressions | |
open System.Text | |
open FSharp.Core | |
-open NBitcoin | |
open GWallet.Backend | |
open GWallet.Backend.FSharpUtil | |
open GWallet.Backend.UtxoCoin.Lightning | |
+open GWallet.Backend.UtxoCoin.Lightning.Lightning | |
open GWallet.Frontend.Console | |
let random = Org.BouncyCastle.Security.SecureRandom () :> Random | |
@@ -292,44 +292,25 @@ let WalletOptions(): unit = | |
WipeWallet() | |
| _ -> () | |
-type ChannelCreationDetails = | |
- { | |
- Seed: NBitcoin.uint256 | |
- AcceptChannel: DotNetLightning.Serialize.Msgs.AcceptChannel | |
- Channel: DotNetLightning.Channel.Channel | |
- Connection: Lightning.Connection | |
- Password: Ref<string> | |
- } | |
- | |
let AskChannelFee (account: UtxoCoin.NormalUtxoAccount) | |
(channelCapacity: TransferAmount) | |
(balance: decimal) | |
(channelCounterpartyIP: System.Net.IPEndPoint) | |
- (channelCounterpartyPubKey: NBitcoin.PubKey) | |
+ (channelCounterpartyPubKey: PublicKey) | |
: Async<Option<ChannelCreationDetails>> = | |
- let witScriptIdLength = 32 | |
- // this dummy address is only used for fee estimation | |
- let nullScriptId = NBitcoin.WitScriptId (Array.zeroCreate witScriptIdLength) | |
- let dummyAddr = NBitcoin.BitcoinWitScriptAddress (nullScriptId, Config.BitcoinNet) | |
- | |
Infrastructure.LogDebug "Calling EstimateFee..." | |
let metadata = | |
try | |
- UtxoCoin.Account.EstimateFeeForDestination | |
- account channelCapacity dummyAddr | |
+ UtxoCoin.Account.EstimateChannelOpeningFee | |
+ account channelCapacity | |
|> Async.RunSynchronously | |
with | |
| InsufficientBalanceForFee _ -> | |
failwith "Estimated fee is too high for the remaining balance, \ | |
use a different account or a different amount." | |
- let channelKeysSeed, keyRepo, temporaryChannelId = Lightning.GetSeedAndRepo random | |
- let channelEnvironment: Lightning.ChannelEnvironment = | |
- { | |
- Account = account | |
- NodeIdForResponder = DotNetLightning.Utils.Primitives.NodeId channelCounterpartyPubKey | |
- KeyRepo = keyRepo | |
- } | |
+ let potentialChannel, channelEnvironment = | |
+ Lightning.GenerateNewPotentialChannelDetails account channelCounterpartyPubKey random | |
async { | |
let! connectionBeforeAcceptChannelRes = | |
Lightning.ConnectAndHandshake channelEnvironment channelCounterpartyIP | |
@@ -340,38 +321,36 @@ let AskChannelFee (account: UtxoCoin.NormalUtxoAccount) | |
| Result.Ok connectionBeforeAcceptChannel -> | |
let passwordRef = ref "DotNetLightning shouldn't ask for password until later when user has | |
confirmed the funding transaction fee. So this is a placeholder." | |
- let! acceptChannelRes = | |
+ let! maybeAcceptChannel = | |
Lightning.GetAcceptChannel | |
+ potentialChannel | |
channelEnvironment | |
connectionBeforeAcceptChannel | |
channelCapacity | |
metadata | |
(fun _ -> !passwordRef) | |
balance | |
- temporaryChannelId | |
- match acceptChannelRes with | |
+ match maybeAcceptChannel with | |
| Result.Error error -> | |
Console.WriteLine error.Message | |
return None | |
- | Result.Ok (acceptChannel, chan, peer) -> | |
+ | Result.Ok fullChannel -> | |
Presentation.ShowFee Currency.BTC metadata | |
+ let confsReq = (fullChannel :> IChannelToBeOpened).ConfirmationsRequired | |
printfn | |
"Opening a channel with this party will require %i confirmations (~%i minutes)" | |
- acceptChannel.MinimumDepth.Value | |
- (acceptChannel.MinimumDepth.Value * 10u) | |
+ confsReq | |
+ (confsReq * 10u) | |
let accept = UserInteraction.AskYesNo "Do you accept?" | |
return | |
if accept then | |
- let connectionWithNewPeer = { connectionBeforeAcceptChannel with Peer = peer } | |
- Some | |
- { | |
- ChannelCreationDetails.Seed = channelKeysSeed | |
- AcceptChannel = acceptChannel | |
- Channel = chan | |
- Connection = connectionWithNewPeer | |
- Password = passwordRef | |
- } | |
+ { | |
+ Client = connectionBeforeAcceptChannel.Client | |
+ Password = passwordRef | |
+ ChannelInfo = potentialChannel | |
+ FullChannel = fullChannel | |
+ } |> Some | |
else | |
connectionBeforeAcceptChannel.Client.Dispose() | |
None | |
@@ -425,10 +404,11 @@ let rec PerformOperation (numAccounts: int) = | |
| Operations.Options -> | |
WalletOptions() | |
| Operations.OpenChannel -> | |
+ let currency = Currency.BTC | |
let btcAccount = Account | |
.GetAllActiveAccounts() | |
.OfType<UtxoCoin.NormalUtxoAccount>() | |
- .Single(fun account -> (account :> IAccount).Currency = Currency.BTC) | |
+ .Single(fun account -> (account :> IAccount).Currency = currency) | |
let balance = Account.GetShowableBalance | |
btcAccount ServerSelectionMode.Fast None | |
|> Async.RunSynchronously | |
@@ -436,7 +416,7 @@ let rec PerformOperation (numAccounts: int) = | |
FSharpUtil.option { | |
let! balance = OptionFromMaybeCachedBalance balance | |
let! channelCapacity = UserInteraction.AskAmount btcAccount | |
- let! ipEndpoint, pubKey = UserInteraction.AskChannelCounterpartyConnectionDetails() | |
+ let! ipEndpoint, pubKey = UserInteraction.AskChannelCounterpartyConnectionDetails currency | |
Infrastructure.LogDebug "Getting channel fee..." | |
let! channelCreationDetails = | |
AskChannelFee | |
@@ -461,14 +441,10 @@ let rec PerformOperation (numAccounts: int) = | |
let txIdRes = | |
Lightning.ContinueFromAcceptChannelAndSave | |
btcAccount | |
- details.Seed | |
ipEndpoint | |
- details.AcceptChannel | |
- details.Channel | |
- (details.Connection.Client.GetStream()) | |
- details.Connection.Peer | |
+ details | |
|> Async.RunSynchronously | |
- details.Connection.Client.Dispose() | |
+ details.Client.Dispose() | |
Some txIdRes | |
with | |
| :? InvalidPassword -> | |
@@ -534,7 +510,7 @@ let rec CheckArchivedAccountsAreEmpty(): bool = | |
not (archivedAccountsInNeedOfAction.Any()) | |
let private NotReadyReasonToString (reason: Lightning.NotReadyReason): string = | |
- sprintf "%i out of %i confirmations" reason.CurrentConfirmations.Value reason.NeededConfirmations.Value | |
+ sprintf "%i out of %i confirmations" reason.CurrentConfirmations reason.NeededConfirmations | |
let private CheckChannelStatus (path: string, channelFileId: int): Async<seq<string>> = | |
async { | |
@@ -560,7 +536,7 @@ let private CheckChannelStatus (path: string, channelFileId: int): Async<seq<str | |
let private CheckChannelStatuses(): Async<seq<string>> = | |
async { | |
- let jobs = SerializedChannel.ListSavedChannels () |> Seq.map CheckChannelStatus | |
+ let jobs = ChannelManager.ListSavedChannels () |> Seq.map CheckChannelStatus | |
let! statuses = Async.Parallel jobs | |
return Seq.collect id statuses | |
} | |
diff --git a/src/GWallet.Frontend.Console/UserInteraction.fs b/src/GWallet.Frontend.Console/UserInteraction.fs | |
index ece21074..f5142589 100644 | |
--- a/src/GWallet.Frontend.Console/UserInteraction.fs | |
+++ b/src/GWallet.Frontend.Console/UserInteraction.fs | |
@@ -660,13 +660,13 @@ module UserInteraction = | |
IPEndPoint(AskChannelCounterpartyIP(), AskChannelCounterpartyPort()) | |
// Throws FormatException | |
- let private AskChannelCounterpartyPubKey(): NBitcoin.PubKey = | |
+ let private AskChannelCounterpartyPubKey (currency: Currency): PublicKey = | |
Console.Write "Channel counterparty public key in hexadecimal notation: " | |
- let pubkeyHex = Console.ReadLine().Trim() | |
- NBitcoin.PubKey pubkeyHex | |
+ let pubKeyHex = Console.ReadLine().Trim() | |
+ PublicKey(pubKeyHex, currency) | |
// Throws FormatException | |
- let private AskChannelCounterpartyQRString(): IPEndPoint * NBitcoin.PubKey = | |
+ let private AskChannelCounterpartyQRString (currency: Currency): IPEndPoint * PublicKey = | |
Console.Write "Channel counterparty QR connection string contents: " | |
let connectionString = Console.ReadLine().Trim() | |
let atIndex = connectionString.IndexOf "@" | |
@@ -679,16 +679,16 @@ module UserInteraction = | |
let ipString, portString = ipPortCombo.[..portSeparatorIndex - 1], ipPortCombo.[portSeparatorIndex + 1..] | |
let ipAddress = IPAddress.Parse ipString | |
let port: int = ParsePortString portString | |
- IPEndPoint(ipAddress, port), NBitcoin.PubKey pubKeyHex | |
+ IPEndPoint(ipAddress, port), PublicKey(pubKeyHex, currency) | |
- let AskChannelCounterpartyConnectionDetails(): Option<IPEndPoint * NBitcoin.PubKey> = | |
+ let AskChannelCounterpartyConnectionDetails (currency: Currency): Option<IPEndPoint * PublicKey> = | |
let useQRString = AskYesNo "Do you want to supply the channel counterparty connection string as used embedded in QR codes?" | |
try | |
if useQRString then | |
- Some <| AskChannelCounterpartyQRString() | |
+ Some <| AskChannelCounterpartyQRString currency | |
else | |
let ipEndpoint = AskChannelCounterpartyIPAndPort() | |
- let pubKey = AskChannelCounterpartyPubKey() | |
+ let pubKey = AskChannelCounterpartyPubKey currency | |
Some (ipEndpoint, pubKey) | |
with | |
| :? FormatException as e -> | |
diff --git a/src/GWallet.Frontend.Console/packages.config b/src/GWallet.Frontend.Console/packages.config | |
index 20738d4e..bf2f39b6 100644 | |
--- a/src/GWallet.Frontend.Console/packages.config | |
+++ b/src/GWallet.Frontend.Console/packages.config | |
@@ -1,10 +1,8 @@ | |
<?xml version="1.0" encoding="utf-8"?> | |
<packages> | |
<package id="BouncyCastle" version="1.8.5" targetFramework="net461" /> | |
- <package id="DotNetLightning" version="1.1.2-date20200527-1047-git-42c7cb9" targetFramework="net472" /> | |
<package id="FSharp.Core" version="4.7.0" targetFramework="net45" /> | |
<package id="Microsoft.Extensions.Logging.Abstractions" version="1.0.0" targetFramework="net461" /> | |
- <package id="NBitcoin" version="5.0.13" targetFramework="net461" /> | |
<package id="Newtonsoft.Json" version="11.0.2" targetFramework="net46" /> | |
<package id="Portable.BouncyCastle" version="1.8.6" targetFramework="net461" /> | |
<package id="System.Buffers" version="4.5.0" targetFramework="net461" /> | |
-- | |
2.21.0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment