1
0
Fork 0
mirror of https://github.com/ACINQ/eclair.git synced 2025-03-13 11:35:47 +01:00

Handling optional amount in a Payment Request (#241)

* Enable generation of a payment request without amount

The amount field in a `PaymentRequest` was already optional but eclair
did not permit the generation of such a request.

Added a new `receive` service with no required amount field.

In the GUI, the parsing of the amount field and its conversion to
`MilliSatoshi` are reworked to better handle decimals.

* (gui) Amount's can be overriden when sending a payment request

The amount of a payment request can be changed and it is up to the
receiving node to accept or deny the payment according to its
implementation.

This also enables the user to pay through the GUI a payment request
where the amount has not been set, such as a donation. The amount is still
required!

The description field has also been added in the GUI. It is empty if the
description has not been set.

* (gui) Properly parse amounts from open channel form

* (gui) added optional `lightning:` scheme to payment request
This commit is contained in:
Dominique 2017-11-30 15:55:29 +01:00 committed by Pierre-Marie Padiou
parent ffc4172e70
commit 44e7c3ba31
22 changed files with 287 additions and 156 deletions

View file

@ -131,6 +131,7 @@ java -Declair.datadir=/tmp/node1 -jar eclair-node-gui-<version>-<commit_id>.jar
channel | channelId | retrieve detailed information about a given channel
allnodes | | list all known nodes
allchannels | | list all known channels
receive | description | generate a payment request without a required amount (can be useful for donations)
receive | amountMsat, description | generate a payment request for a given amount
send | amountMsat, paymentHash, nodeId | send a payment to a lightning node
send | paymentRequest | send a payment to a lightning node using a BOLT11 payment request

View file

@ -95,8 +95,10 @@ trait Service extends Logging {
(router ? 'nodes).mapTo[Iterable[NodeAnnouncement]].map(_.map(_.nodeId))
case JsonRPCBody(_, _, "allchannels", _) =>
(router ? 'channels).mapTo[Iterable[ChannelAnnouncement]].map(_.map(c => ChannelInfo(c.shortChannelId.toHexString, c.nodeId1, c.nodeId2)))
case JsonRPCBody(_, _, "receive", JString(description) :: Nil) =>
(paymentHandler ? ReceivePayment(None, description)).mapTo[PaymentRequest].map(PaymentRequest.write)
case JsonRPCBody(_, _, "receive", JInt(amountMsat) :: JString(description) :: Nil) =>
(paymentHandler ? ReceivePayment(MilliSatoshi(amountMsat.toLong), description)).mapTo[PaymentRequest].map(PaymentRequest.write)
(paymentHandler ? ReceivePayment(Some(MilliSatoshi(amountMsat.toLong)), description)).mapTo[PaymentRequest].map(PaymentRequest.write)
case JsonRPCBody(_, _, "send", JInt(amountMsat) :: JString(paymentHash) :: JString(nodeId) :: Nil) =>
(paymentInitiator ? SendPayment(amountMsat.toLong, paymentHash, PublicKey(nodeId))).mapTo[PaymentResult]
case JsonRPCBody(_, _, "send", JString(paymentRequest) :: rest) =>

View file

@ -17,14 +17,14 @@ class LocalPaymentHandler(nodeParams: NodeParams) extends Actor with ActorLoggin
def run(h2r: Map[BinaryData, (BinaryData, PaymentRequest)]): Receive = {
case ReceivePayment(amount, desc) =>
case ReceivePayment(amount_opt, desc) =>
Try {
val paymentPreimage = randomBytes(32)
val paymentHash = Crypto.sha256(paymentPreimage)
(paymentPreimage, paymentHash, PaymentRequest(nodeParams.chainHash, Some(amount), paymentHash, nodeParams.privateKey, desc))
(paymentPreimage, paymentHash, PaymentRequest(nodeParams.chainHash, amount_opt, paymentHash, nodeParams.privateKey, desc))
} match {
case Success((r, h, pr)) =>
log.debug(s"generated payment request=${PaymentRequest.write(pr)} from amount=$amount")
log.debug(s"generated payment request=${PaymentRequest.write(pr)} from amount=$amount_opt")
sender ! pr
context.become(run(h2r + (h -> (r, pr))))
case Failure(t) =>

View file

@ -12,7 +12,7 @@ import fr.acinq.eclair.wire._
import scodec.Attempt
// @formatter:off
case class ReceivePayment(amountMsat: MilliSatoshi, description: String)
case class ReceivePayment(amountMsat_opt: Option[MilliSatoshi], description: String)
case class SendPayment(amountMsat: Long, paymentHash: BinaryData, targetNodeId: PublicKey, minFinalCltvExpiry: Long = PaymentLifecycle.defaultMinFinalCltvExpiry, maxAttempts: Int = 5)
sealed trait PaymentResult

View file

@ -26,7 +26,7 @@ import scala.util.Try
*/
case class PaymentRequest(prefix: String, amount: Option[MilliSatoshi], timestamp: Long, nodeId: PublicKey, tags: List[PaymentRequest.Tag], signature: BinaryData) {
amount.map(a => require(a > MilliSatoshi(0) && a <= PaymentRequest.maxAmount, s"amount is not valid"))
amount.map(a => require(a.amount > 0 && a.amount <= PaymentRequest.MAX_AMOUNT.amount, s"amount is not valid"))
require(tags.collect { case _: PaymentRequest.PaymentHashTag => {} }.size == 1, "there must be exactly one payment hash tag")
require(tags.collect { case PaymentRequest.DescriptionTag(_) | PaymentRequest.DescriptionHashTag(_) => {} }.size == 1, "there must be exactly one description tag or one description hash tag")
@ -102,7 +102,7 @@ case class PaymentRequest(prefix: String, amount: Option[MilliSatoshi], timestam
object PaymentRequest {
// https://github.com/lightningnetwork/lightning-rfc/blob/master/02-peer-protocol.md#adding-an-htlc-update_add_htlc
val maxAmount = MilliSatoshi(4294967296L)
val MAX_AMOUNT = MilliSatoshi(4294967296L)
def apply(chainHash: BinaryData, amount: Option[MilliSatoshi], paymentHash: BinaryData, privateKey: PrivateKey,
description: String, fallbackAddress: Option[String] = None, expirySeconds: Option[Long] = None,

View file

@ -83,7 +83,7 @@ class FuzzySpec extends TestkitBaseClass with StateTestsHelperMethods with Loggi
if (stopping) {
context stop self
} else {
paymentHandler ! ReceivePayment(MilliSatoshi(requiredAmount), "One coffee")
paymentHandler ! ReceivePayment(Some(MilliSatoshi(requiredAmount)), "One coffee")
context become waitingForPaymentRequest
}

View file

@ -255,7 +255,7 @@ class BasicIntegrationSpvSpec extends TestKit(ActorSystem("test")) with FunSuite
val sender = TestProbe()
val amountMsat = MilliSatoshi(4200000)
// first we retrieve a payment hash from D
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("D").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
// then we make the actual payment
sender.send(nodes("A").paymentInitiator,
@ -273,7 +273,7 @@ class BasicIntegrationSpvSpec extends TestKit(ActorSystem("test")) with FunSuite
sender.send(nodes("C").relayer, channelUpdateCD)
// first we retrieve a payment hash from D
val amountMsat = MilliSatoshi(4200000)
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("D").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
// then we make the actual payment
val sendReq = SendPayment(amountMsat.amount, pr.paymentHash, nodes("D").nodeParams.privateKey.publicKey)
@ -292,7 +292,7 @@ class BasicIntegrationSpvSpec extends TestKit(ActorSystem("test")) with FunSuite
val sender = TestProbe()
// first we retrieve a payment hash from D
val amountMsat = MilliSatoshi(300000000L)
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("D").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
// then we make the payment (C-D has a smaller capacity than A-B and B-C)
val sendReq = SendPayment(amountMsat.amount, pr.paymentHash, nodes("D").nodeParams.privateKey.publicKey)
@ -317,7 +317,7 @@ class BasicIntegrationSpvSpec extends TestKit(ActorSystem("test")) with FunSuite
val sender = TestProbe()
// first we retrieve a payment hash from D for 2 mBTC
val amountMsat = MilliSatoshi(200000000L)
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("D").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
// A send payment of only 1 mBTC
@ -335,7 +335,7 @@ class BasicIntegrationSpvSpec extends TestKit(ActorSystem("test")) with FunSuite
val sender = TestProbe()
// first we retrieve a payment hash from D for 2 mBTC
val amountMsat = MilliSatoshi(200000000L)
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("D").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
// A send payment of 6 mBTC
@ -353,7 +353,7 @@ class BasicIntegrationSpvSpec extends TestKit(ActorSystem("test")) with FunSuite
val sender = TestProbe()
// first we retrieve a payment hash from D for 2 mBTC
val amountMsat = MilliSatoshi(200000000L)
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("D").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
// A send payment of 3 mBTC, more than asked but it should still be accepted

View file

@ -217,7 +217,7 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit
val sender = TestProbe()
val amountMsat = MilliSatoshi(4200000)
// first we retrieve a payment hash from D
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("D").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
// then we make the actual payment
sender.send(nodes("A").paymentInitiator,
@ -235,7 +235,7 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit
sender.send(nodes("C").relayer, channelUpdateCD)
// first we retrieve a payment hash from D
val amountMsat = MilliSatoshi(4200000)
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("D").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
// then we make the actual payment
val sendReq = SendPayment(amountMsat.amount, pr.paymentHash, nodes("D").nodeParams.privateKey.publicKey)
@ -254,7 +254,7 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit
val sender = TestProbe()
// first we retrieve a payment hash from D
val amountMsat = MilliSatoshi(300000000L)
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("D").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
// then we make the payment (C-D has a smaller capacity than A-B and B-C)
val sendReq = SendPayment(amountMsat.amount, pr.paymentHash, nodes("D").nodeParams.privateKey.publicKey)
@ -279,7 +279,7 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit
val sender = TestProbe()
// first we retrieve a payment hash from D for 2 mBTC
val amountMsat = MilliSatoshi(200000000L)
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("D").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
// A send payment of only 1 mBTC
@ -297,7 +297,7 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit
val sender = TestProbe()
// first we retrieve a payment hash from D for 2 mBTC
val amountMsat = MilliSatoshi(200000000L)
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("D").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
// A send payment of 6 mBTC
@ -315,7 +315,7 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit
val sender = TestProbe()
// first we retrieve a payment hash from D for 2 mBTC
val amountMsat = MilliSatoshi(200000000L)
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("D").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
// A send payment of 3 mBTC, more than asked but it should still be accepted
@ -574,7 +574,7 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit
awaitCond(Globals.blockCount.get() == currentBlockCount, max = 20 seconds, interval = 1 second)
// first we send 3 mBTC to F so that it has a balance
val amountMsat = MilliSatoshi(300000000L)
sender.send(nodes("F5").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(nodes("F5").paymentHandler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
val sendReq = SendPayment(300000000L, pr.paymentHash, nodes("F5").nodeParams.privateKey.publicKey)
sender.send(nodes("A").paymentInitiator, sendReq)
@ -592,7 +592,7 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit
val localCommitTxF = sender.expectMsgType[DATA_NORMAL].commitments.localCommit.publishableTxs
// we now send some more money to F so that it creates a new commitment tx
val amountMsat1 = MilliSatoshi(100000000L)
sender.send(nodes("F5").paymentHandler, ReceivePayment(amountMsat1, "1 coffee"))
sender.send(nodes("F5").paymentHandler, ReceivePayment(Some(amountMsat1), "1 coffee"))
val pr1 = sender.expectMsgType[PaymentRequest]
val sendReq1 = SendPayment(100000000L, pr1.paymentHash, nodes("F5").nodeParams.privateKey.publicKey)
sender.send(nodes("A").paymentInitiator, sendReq1)

View file

@ -24,7 +24,7 @@ class PaymentHandlerSpec extends TestKit(ActorSystem("test")) with FunSuiteLike
system.eventStream.subscribe(eventListener.ref, classOf[PaymentReceived])
val amountMsat = MilliSatoshi(42000)
sender.send(handler, ReceivePayment(amountMsat, "1 coffee"))
sender.send(handler, ReceivePayment(Some(amountMsat), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
val add = UpdateAddHtlc("11" * 32, 0, amountMsat.amount, pr.paymentHash, 0, "")
@ -40,23 +40,34 @@ class PaymentHandlerSpec extends TestKit(ActorSystem("test")) with FunSuiteLike
system.eventStream.subscribe(eventListener.ref, classOf[PaymentReceived])
// negative amount should fail
sender.send(handler, ReceivePayment(MilliSatoshi(-50), "1 coffee"))
sender.send(handler, ReceivePayment(Some(MilliSatoshi(-50)), "1 coffee"))
val negativeError = sender.expectMsgType[Failure]
assert(negativeError.cause.getMessage.contains("amount is not valid"))
// amount = 0 should fail
sender.send(handler, ReceivePayment(MilliSatoshi(0), "1 coffee"))
sender.send(handler, ReceivePayment(Some(MilliSatoshi(0)), "1 coffee"))
val zeroError = sender.expectMsgType[Failure]
assert(zeroError.cause.getMessage.contains("amount is not valid"))
// large amount should fail (> 42.95 mBTC)
sender.send(handler, ReceivePayment(Satoshi(1) + PaymentRequest.maxAmount, "1 coffee"))
sender.send(handler, ReceivePayment(Some(Satoshi(1) + PaymentRequest.MAX_AMOUNT), "1 coffee"))
val largeAmountError = sender.expectMsgType[Failure]
assert(largeAmountError.cause.getMessage.contains("amount is not valid"))
// success with 1 mBTC
sender.send(handler, ReceivePayment(MilliSatoshi(100000000L), "1 coffee"))
sender.send(handler, ReceivePayment(Some(MilliSatoshi(100000000L)), "1 coffee"))
val pr = sender.expectMsgType[PaymentRequest]
assert(pr.amount == Some(MilliSatoshi(100000000L)) && pr.nodeId.toString == Alice.nodeParams.privateKey.publicKey.toString)
}
test("Payment request generation should succeed when the amount is not set") {
val handler = system.actorOf(LocalPaymentHandler.props(Alice.nodeParams))
val sender = TestProbe()
val eventListener = TestProbe()
system.eventStream.subscribe(eventListener.ref, classOf[PaymentReceived])
sender.send(handler, ReceivePayment(None, "This is a donation PR"))
val pr = sender.expectMsgType[PaymentRequest]
assert(pr.amount == None && pr.nodeId.toString == Alice.nodeParams.privateKey.publicKey.toString)
}
}

View file

@ -207,4 +207,12 @@
}
.button.copy-clipboard:pressed {
-fx-background-color: rgb(220,222,225);
}
/* --------- send modal ---------- */
.text-field.description-text, .text-area.description-text {
-fx-background-color: rgb(235,235,235);
-fx-text-fill: rgb(100,100,100);
-fx-padding: 4px;
}

View file

@ -30,8 +30,7 @@
</VBox>
<TextField fx:id="host" prefWidth="313.0" promptText="pubkey@host:port"
GridPane.columnIndex="1" GridPane.columnSpan="2" GridPane.rowIndex="0"/>
<Label fx:id="hostError" opacity="0.0" styleClass="text-error, text-error-downward" text="Generic Invalid URI"
mouseTransparent="true"
<Label fx:id="hostError" styleClass="text-error, text-error-downward" mouseTransparent="true"
GridPane.rowIndex="0" GridPane.columnIndex="1" GridPane.columnSpan="2"/>
<CheckBox fx:id="simpleConnection" mnemonicParsing="false" text="Simple connection (no channel)"
@ -55,8 +54,7 @@
</FXCollections>
</items>
</ComboBox>
<Label fx:id="fundingSatoshisError" opacity="0.0" styleClass="text-error, text-error-downward"
text="Generic Invalid Funding"
<Label fx:id="fundingSatoshisError" styleClass="text-error, text-error-downward"
GridPane.columnIndex="1" GridPane.columnSpan="2" GridPane.rowIndex="2"/>
<Label styleClass="text-muted" text="Optional Parameters" wrapText="true" GridPane.columnIndex="0"
@ -71,10 +69,9 @@
wrapText="true"/>
</children>
</VBox>
<TextField fx:id="pushMsat" prefWidth="313.0" GridPane.columnIndex="1" GridPane.rowIndex="4"/>
<Label fx:id="pushMsatError" opacity="0.0" styleClass="text-error, text-error-downward"
text="Generic Invalid Push" GridPane.columnSpan="2"
GridPane.columnIndex="1" GridPane.rowIndex="4"/>
<TextField fx:id="pushMsatField" prefWidth="313.0" GridPane.columnIndex="1" GridPane.rowIndex="4"/>
<Label fx:id="pushMsatError" styleClass="text-error, text-error-downward"
GridPane.columnIndex="1" GridPane.rowIndex="4" GridPane.columnSpan="2"/>
<CheckBox fx:id="publicChannel" mnemonicParsing="true" selected="true" styleClass="text-sm"
text="Public Channel"
GridPane.columnIndex="1" GridPane.columnSpan="2" GridPane.rowIndex="5"/>

View file

@ -19,11 +19,12 @@
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
</rowConstraints>
<children>
<VBox alignment="TOP_RIGHT" GridPane.rowIndex="0">
<children>
<Label styleClass="text-strong" text="Amount to receive"/>
<Label styleClass="text-strong" text="Optional amount to receive"/>
<Label styleClass="label-description" wrapText="true" textAlignment="RIGHT"
text="Maximum of ~0.042 BTC"/>
</children>
@ -52,11 +53,18 @@
<TextArea fx:id="description" GridPane.columnIndex="1" GridPane.rowIndex="1" GridPane.columnSpan="2"
wrapText="true" prefHeight="50.0"/>
<VBox alignment="TOP_RIGHT" GridPane.rowIndex="2" GridPane.columnIndex="0">
<children>
<Label styleClass="text-strong" text="Add the lightning: prefix"/>
</children>
</VBox>
<CheckBox fx:id="prependPrefixCheckbox" selected="false" GridPane.rowIndex="2" GridPane.columnIndex="1"/>
<Button defaultButton="true" mnemonicParsing="false" onAction="#handleGenerate" prefHeight="29.0"
prefWidth="95.0" text="Generate" GridPane.columnIndex="1" GridPane.rowIndex="2"/>
prefWidth="95.0" text="Generate" GridPane.columnIndex="1" GridPane.rowIndex="3"/>
<Button cancelButton="true" mnemonicParsing="false" onAction="#handleClose" styleClass="cancel"
text="Close"
GridPane.columnIndex="2" GridPane.halignment="RIGHT" GridPane.rowIndex="2" opacity="0"
GridPane.columnIndex="2" GridPane.halignment="RIGHT" GridPane.rowIndex="3" opacity="0"
focusTraversable="false"/>
</children>

View file

@ -4,7 +4,7 @@
<?import javafx.scene.layout.*?>
<?import java.lang.String?>
<?import java.net.URL?>
<GridPane fx:id="nodeId" prefWidth="450.0" prefHeight="450.0" xmlns="http://javafx.com/javafx/8"
<GridPane fx:id="nodeId" prefWidth="550.0" prefHeight="550.0" xmlns="http://javafx.com/javafx/8"
xmlns:fx="http://javafx.com/fxml/1">
<columnConstraints>
<ColumnConstraints halignment="LEFT" hgrow="SOMETIMES" minWidth="10.0" prefWidth="110.0"/>
@ -19,33 +19,47 @@
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
<RowConstraints minHeight="1.0" vgrow="SOMETIMES"/>
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
</rowConstraints>
<children>
<Label styleClass="text-strong" text="Enter a Payment Request below" GridPane.columnSpan="2"
GridPane.valignment="TOP"/>
<TextArea fx:id="paymentRequest" minHeight="150.0" prefHeight="150.0" styleClass="ta" wrapText="true"
GridPane.columnSpan="2" GridPane.rowIndex="1" GridPane.vgrow="ALWAYS"/>
<Label fx:id="paymentRequestError" opacity="0.0" text="Generic Invalid Payment Request" mouseTransparent="true"
styleClass="text-error" GridPane.columnSpan="2" GridPane.rowIndex="2"/>
<Label styleClass="text-muted" text="Amount (msat)" GridPane.halignment="RIGHT" GridPane.rowIndex="3"/>
<TextField fx:id="amountField" focusTraversable="false" editable="false" styleClass="noteditable" text="0"
<Label fx:id="paymentRequestError" mouseTransparent="true" styleClass="text-error" GridPane.columnSpan="2"
GridPane.rowIndex="2"/>
<Label text="Node Id" GridPane.halignment="RIGHT" GridPane.rowIndex="3"/>
<TextField fx:id="nodeIdField" focusTraversable="false" editable="false" styleClass="description-text"
text="N/A"
GridPane.columnIndex="1" GridPane.rowIndex="3"/>
<Label styleClass="text-muted" text="Node Id" GridPane.halignment="RIGHT" GridPane.rowIndex="4"/>
<TextField fx:id="nodeIdField" focusTraversable="false" editable="false" styleClass="noteditable" text="N/A"
<Label text="Payment Hash" GridPane.halignment="RIGHT" GridPane.rowIndex="4"/>
<TextField fx:id="paymentHashField" focusTraversable="false" editable="false" styleClass="description-text"
text="N/A"
GridPane.columnIndex="1" GridPane.rowIndex="4"/>
<Label styleClass="text-muted" text="hash" GridPane.halignment="RIGHT" GridPane.rowIndex="5"/>
<TextField fx:id="hashField" focusTraversable="false" editable="false" styleClass="noteditable" text="N/A"
GridPane.columnIndex="1" GridPane.rowIndex="5"/>
<Label fx:id="descriptionLabel" text="Description" GridPane.halignment="RIGHT" GridPane.valignment="BASELINE"
GridPane.rowIndex="5"/>
<TextArea fx:id="descriptionField" focusTraversable="false" editable="false"
styleClass="noteditable, description-text" text="N/A"
prefHeight="80.0" maxHeight="80.0" GridPane.columnIndex="1" GridPane.rowIndex="5"/>
<Separator GridPane.columnSpan="2" GridPane.rowIndex="6"/>
<Label text="Amount (msat)" GridPane.halignment="RIGHT" GridPane.valignment="BASELINE" GridPane.rowIndex="6"/>
<VBox GridPane.columnIndex="1" GridPane.rowIndex="6">
<children>
<TextField fx:id="amountField"/>
<Label fx:id="amountFieldError" mouseTransparent="true" styleClass="text-error"/>
</children>
</VBox>
<Separator GridPane.columnSpan="2" GridPane.rowIndex="7"/>
<Button fx:id="sendButton" defaultButton="true" mnemonicParsing="false" onAction="#handleSend" text="Send"
GridPane.rowIndex="7"/>
GridPane.rowIndex="8"/>
<Button cancelButton="true" mnemonicParsing="false" onAction="#handleClose" styleClass="cancel" text="Cancel"
GridPane.columnIndex="1" GridPane.halignment="RIGHT" GridPane.rowIndex="7"/>
GridPane.columnIndex="1" GridPane.halignment="RIGHT" GridPane.rowIndex="8"/>
</children>
<styleClass>
<String fx:value="grid"/>
@ -53,5 +67,6 @@
</styleClass>
<stylesheets>
<URL value="@../commons/globals.css"/>
<URL value="@../main/main.css"/>
</stylesheets>
</GridPane>

View file

@ -14,7 +14,7 @@ import fr.acinq.eclair.blockchain.bitcoind.zmq.ZMQActor.{ZMQConnected, ZMQDiscon
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.{ElectrumConnected, ElectrumDisconnected}
import fr.acinq.eclair.channel._
import fr.acinq.eclair.gui.controllers._
import fr.acinq.eclair.gui.utils.CoinFormat
import fr.acinq.eclair.gui.utils.CoinUtils
import fr.acinq.eclair.payment.{PaymentReceived, PaymentRelayed, PaymentSent}
import fr.acinq.eclair.router._
import fr.acinq.eclair.wire.NodeAnnouncement
@ -52,8 +52,8 @@ class GUIUpdater(mainController: MainController) extends Actor with ActorLogging
def updateBalance(channelPaneController: ChannelPaneController, commitments: Commitments) = {
val spec = commitments.localCommit.spec
channelPaneController.capacity.setText(s"${CoinFormat.MILLI_BTC_FORMAT.format(millisatoshi2millibtc(MilliSatoshi(spec.totalFunds)).amount)}")
channelPaneController.amountUs.setText(s"${CoinFormat.MILLI_BTC_FORMAT.format(millisatoshi2millibtc(MilliSatoshi(spec.toLocalMsat)).amount)}")
channelPaneController.capacity.setText(s"${CoinUtils.MILLI_BTC_FORMAT.format(millisatoshi2millibtc(MilliSatoshi(spec.totalFunds)).amount)}")
channelPaneController.amountUs.setText(s"${CoinUtils.MILLI_BTC_FORMAT.format(millisatoshi2millibtc(MilliSatoshi(spec.toLocalMsat)).amount)}")
channelPaneController.balanceBar.setProgress(spec.toLocalMsat.toDouble / spec.totalFunds)
}

View file

@ -86,9 +86,9 @@ class Handlers(fKit: Future[Kit])(implicit ec: ExecutionContext = ExecutionConte
}
}
def receive(amountMsat: MilliSatoshi, description: String): Future[String] = for {
def receive(amountMsat_opt: Option[MilliSatoshi], description: String): Future[String] = for {
kit <- fKit
res <- (kit.paymentHandler ? ReceivePayment(amountMsat, description)).mapTo[PaymentRequest].map(PaymentRequest.write(_))
res <- (kit.paymentHandler ? ReceivePayment(amountMsat_opt, description)).mapTo[PaymentRequest].map(PaymentRequest.write(_))
} yield res

View file

@ -7,13 +7,14 @@ import javafx.fxml.FXML
import javafx.scene.control._
import javafx.stage.Stage
import fr.acinq.bitcoin.{MilliSatoshi, Satoshi}
import fr.acinq.eclair.channel.{Channel, ChannelFlags}
import fr.acinq.eclair.gui.Handlers
import fr.acinq.eclair.gui.utils.GUIValidators
import fr.acinq.eclair.gui.utils.{CoinUtils, GUIValidators}
import fr.acinq.eclair.io.Switchboard.NewChannel
import grizzled.slf4j.Logging
import scala.util.{Failure, Success, Try}
/**
* Created by DPA on 23/09/2016.
*/
@ -24,7 +25,7 @@ class OpenChannelController(val handlers: Handlers, val stage: Stage) extends Lo
@FXML var simpleConnection: CheckBox = _
@FXML var fundingSatoshis: TextField = _
@FXML var fundingSatoshisError: Label = _
@FXML var pushMsat: TextField = _
@FXML var pushMsatField: TextField = _
@FXML var pushMsatError: Label = _
@FXML var publicChannel: CheckBox = _
@FXML var unit: ComboBox[String] = _
@ -36,45 +37,61 @@ class OpenChannelController(val handlers: Handlers, val stage: Stage) extends Lo
simpleConnection.selectedProperty.addListener(new ChangeListener[Boolean] {
override def changed(observable: ObservableValue[_ <: Boolean], oldValue: Boolean, newValue: Boolean) = {
fundingSatoshis.setDisable(newValue)
pushMsat.setDisable(newValue)
pushMsatField.setDisable(newValue)
unit.setDisable(newValue)
}
})
}
@FXML def handleOpen(event: ActionEvent) = {
clearErrors()
if (GUIValidators.validate(host.getText, hostError, "Please use a valid url (pubkey@host:port)", GUIValidators.hostRegex)) {
if (simpleConnection.isSelected) {
handlers.open(host.getText, None)
stage.close
} else {
if (GUIValidators.validate(fundingSatoshis.getText, fundingSatoshisError, "Funding must be numeric", GUIValidators.amountRegex)
&& GUIValidators.validate(fundingSatoshisError, "Funding must be greater than 0", fundingSatoshis.getText.toLong > 0)) {
val rawFunding = fundingSatoshis.getText.toLong
val smartFunding = unit.getValue match {
case "milliBTC" => Satoshi(rawFunding * 100000L)
case "Satoshi" => Satoshi(rawFunding)
case "milliSatoshi" => Satoshi(rawFunding / 1000L)
}
if (GUIValidators.validate(fundingSatoshisError, f"Capacity must be less than ${Channel.MAX_FUNDING_SATOSHIS}%d satoshis", smartFunding.toLong < Channel.MAX_FUNDING_SATOSHIS)) {
if (!pushMsat.getText.isEmpty) {
// pushMsat is optional, so we validate field only if it isn't empty
import fr.acinq.bitcoin._
if (GUIValidators.validate(pushMsat.getText, pushMsatError, "Push msat must be numeric", GUIValidators.amountRegex)
&& GUIValidators.validate(pushMsatError, "Push msat must be less or equal to capacity", pushMsat.getText.toLong <= satoshi2millisatoshi(smartFunding).amount)) {
val channelFlags = if (publicChannel.isSelected) ChannelFlags.AnnounceChannel else ChannelFlags.Empty
handlers.open(host.getText, Some(NewChannel(smartFunding, MilliSatoshi(pushMsat.getText.toLong), Some(channelFlags))))
stage.close
}
} else {
handlers.open(host.getText, Some(NewChannel(smartFunding, MilliSatoshi(0), None)))
stage.close
import fr.acinq.bitcoin._
fundingSatoshis.getText match {
case GUIValidators.amountDecRegex(_*) =>
Try(CoinUtils.convertStringAmountToSat(fundingSatoshis.getText, unit.getValue)) match {
case Success(capacitySat) if capacitySat.amount < 0 =>
fundingSatoshisError.setText("Capacity must be greater than 0")
case Success(capacitySat) if capacitySat.amount >= Channel.MAX_FUNDING_SATOSHIS =>
fundingSatoshisError.setText(f"Capacity must be less than ${Channel.MAX_FUNDING_SATOSHIS}%,d sat")
case Success(capacitySat) =>
pushMsatField.getText match {
case "" =>
handlers.open(host.getText, Some(NewChannel(capacitySat, MilliSatoshi(0), None)))
stage close()
case GUIValidators.amountRegex(_*) =>
Try(MilliSatoshi(pushMsatField.getText.toLong)) match {
case Success(pushMsat) if pushMsat.amount > satoshi2millisatoshi(capacitySat).amount =>
pushMsatError.setText("Push must be less or equal to capacity")
case Success(pushMsat) =>
val channelFlags = if (publicChannel.isSelected) ChannelFlags.AnnounceChannel else ChannelFlags.Empty
handlers.open(host.getText, Some(NewChannel(capacitySat, pushMsat, Some(channelFlags))))
stage close()
case Failure(t) =>
logger.error("Could not parse push amount", t)
pushMsatError.setText("Push amount is not valid")
}
case _ => pushMsatError.setText("Push amount is not valid")
}
case Failure(t) =>
logger.error("Could not parse capacity amount", t)
fundingSatoshisError.setText("Capacity is not valid")
}
}
case _ => fundingSatoshisError.setText("Capacity is not valid")
}
}
}
}
private def clearErrors() = {
hostError.setText("")
fundingSatoshisError.setText("")
pushMsatError.setText("")
}
@FXML def handleClose(event: ActionEvent) = stage.close
}

View file

@ -3,7 +3,7 @@ package fr.acinq.eclair.gui.controllers
import javafx.application.Platform
import javafx.event.ActionEvent
import javafx.fxml.FXML
import javafx.scene.control.{ComboBox, Label, TextArea, TextField}
import javafx.scene.control._
import javafx.scene.image.{ImageView, WritableImage}
import javafx.scene.layout.GridPane
import javafx.scene.paint.Color
@ -14,10 +14,11 @@ import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.{BarcodeFormat, EncodeHintType}
import fr.acinq.bitcoin.MilliSatoshi
import fr.acinq.eclair.gui.Handlers
import fr.acinq.eclair.gui.utils.{ContextMenuUtils, GUIValidators}
import fr.acinq.eclair.gui.utils.{CoinUtils, ContextMenuUtils, GUIValidators}
import fr.acinq.eclair.payment.PaymentRequest
import grizzled.slf4j.Logging
import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.{Failure, Success, Try}
/**
@ -29,6 +30,7 @@ class ReceivePaymentController(val handlers: Handlers, val stage: Stage) extends
@FXML var amountError: Label = _
@FXML var unit: ComboBox[String] = _
@FXML var description: TextArea = _
@FXML var prependPrefixCheckbox: CheckBox = _
@FXML var resultBox: GridPane = _
// the content of this field is generated and readonly
@ -43,52 +45,77 @@ class ReceivePaymentController(val handlers: Handlers, val stage: Stage) extends
@FXML def handleCopyInvoice(event: ActionEvent) = ContextMenuUtils.copyToClipboard(paymentRequestTextArea.getText)
/**
* Generates a payment request from the amount/unit set in form. Displays an error if the generation fails.
* Amount field content must obviously be numeric. It is also validated against minimal/maximal HTLC values.
*
* @param event
*/
@FXML def handleGenerate(event: ActionEvent) = {
if ((("milliBTC".equals(unit.getValue) || "Satoshi".equals(unit.getValue))
&& GUIValidators.validate(amount.getText, amountError, "Amount must be numeric", GUIValidators.amountDecRegex))
|| ("milliSatoshi".equals(unit.getValue) && GUIValidators.validate(amount.getText, amountError, "Amount must be numeric (no decimal msat)", GUIValidators.amountRegex))) {
try {
val Array(parsedInt, parsedDec) = if (amount.getText.contains(".")) amount.getText.split("\\.") else Array(amount.getText, "0")
val amountDec = parsedDec.length match {
case 0 => "000"
case 1 => parsedDec.concat("00")
case 2 => parsedDec.concat("0")
case 3 => parsedDec
case _ =>
// amount has too many decimals, regex validation has failed somehow
throw new NumberFormatException("incorrect amount")
clearError()
amount.getText match {
case "" => createPaymentRequest(None)
case GUIValidators.amountDecRegex(_*) =>
Try(CoinUtils.convertStringAmountToMsat(amount.getText, unit.getValue)) match {
case Success(amountMsat) if amountMsat.amount < 0 =>
handleError("Amount must be greater than 0")
case Success(amountMsat) if amountMsat.amount >= PaymentRequest.MAX_AMOUNT.amount =>
handleError(f"Amount must be less than ${PaymentRequest.MAX_AMOUNT.amount}%,d msat (~${PaymentRequest.MAX_AMOUNT.amount / 1e11}%.3f BTC)")
case Failure(_) =>
handleError("Amount is incorrect")
case Success(amountMsat) => createPaymentRequest(Some(amountMsat))
}
val smartAmount = unit.getValue match {
case "milliBTC" => MilliSatoshi(parsedInt.toLong * 100000000L + amountDec.toLong * 100000L)
case "Satoshi" => MilliSatoshi(parsedInt.toLong * 1000L + amountDec.toLong)
case "milliSatoshi" => MilliSatoshi(amount.getText.toLong)
}
if (GUIValidators.validate(amountError, "Amount must be greater than 0", smartAmount.amount > 0)
&& GUIValidators.validate(amountError, f"Amount must be less than ${PaymentRequest.maxAmount.amount}%,d msat (~${PaymentRequest.maxAmount.amount / 1e11}%.3f BTC)", smartAmount < PaymentRequest.maxAmount)
&& GUIValidators.validate(amountError, "Description is too long, max 256 chars.", description.getText().size < 256)) {
import scala.concurrent.ExecutionContext.Implicits.global
handlers.receive(smartAmount, description.getText) onComplete {
case Success(s) =>
Try(createQRCode(s)) match {
case Success(wImage) => displayPaymentRequest(s, Some(wImage))
case Failure(t) => displayPaymentRequest(s, None)
}
case Failure(t) => Platform.runLater(new Runnable {
def run = GUIValidators.validate(amountError, "The payment request could not be generated", false)
})
}
}
} catch {
case e: NumberFormatException =>
logger.debug(s"Could not generate payment request for amount = ${amount.getText}")
paymentRequestTextArea.setText("")
amountError.setText("Amount is incorrect")
amountError.setOpacity(1)
}
case _ => handleError("Amount must be a number")
}
}
private def displayPaymentRequest(pr: String, image: Option[WritableImage]) = Platform.runLater(new Runnable {
/**
* Display error message
*
* @param message
*/
private def handleError(message: String): Unit = {
paymentRequestTextArea.setText("")
amountError.setText(message)
amountError.setOpacity(1)
}
private def clearError(): Unit = {
paymentRequestTextArea.setText("")
amountError.setText("")
amountError.setOpacity(0)
}
/**
* Ask eclair-core to create a Payment Request. If successful a QR code is generated and displayed, otherwise
* an error message is shown.
*
* @param amount_opt optional amount of the payment request, in millisatoshi
*/
private def createPaymentRequest(amount_opt: Option[MilliSatoshi]) = {
logger.debug(s"generate payment request for amount_opt=${amount_opt.getOrElse("N/A")} description=${description.getText()}")
handlers.receive(amount_opt, description.getText) onComplete {
case Success(s) =>
val pr = if (prependPrefixCheckbox.isSelected) s"lightning:$s" else s
Try(createQRCode(pr)) match {
case Success(wImage) => displayPaymentRequestQR(pr, Some(wImage))
case Failure(t) => displayPaymentRequestQR(pr, None)
}
case Failure(t) =>
logger.error("Could not generate payment request", t)
Platform.runLater(new Runnable {
def run = GUIValidators.validate(amountError, "The payment request could not be generated", false)
})
}
}
/**
* Displays a QR Code from a QR code image.
*
* @param pr payment request described by the QR code
* @param image QR code source image
*/
private def displayPaymentRequestQR(pr: String, image: Option[WritableImage]) = Platform.runLater(new Runnable {
def run = {
paymentRequestTextArea.setText(pr)
if ("".equals(pr)) {

View file

@ -8,8 +8,8 @@ import javafx.scene.input.KeyCode.{ENTER, TAB}
import javafx.scene.input.KeyEvent
import javafx.stage.Stage
import fr.acinq.bitcoin.MilliSatoshi
import fr.acinq.eclair.gui.Handlers
import fr.acinq.eclair.gui.utils.GUIValidators
import fr.acinq.eclair.payment.PaymentRequest
import grizzled.slf4j.Logging
@ -24,12 +24,15 @@ class SendPaymentController(val handlers: Handlers, val stage: Stage) extends Lo
@FXML var paymentRequest: TextArea = _
@FXML var paymentRequestError: Label = _
@FXML var nodeIdField: TextField = _
@FXML var descriptionLabel: Label = _
@FXML var descriptionField: TextArea = _
@FXML var amountField: TextField = _
@FXML var hashField: TextField = _
@FXML var amountFieldError: Label = _
@FXML var paymentHashField: TextField = _
@FXML var sendButton: Button = _
@FXML def initialize(): Unit = {
// ENTER or TAB events in the paymentRequest textarea insted fire or focus sendButton
// ENTER or TAB events in the paymentRequest textarea instead fire or focus sendButton
paymentRequest.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler[KeyEvent] {
def handle(event: KeyEvent) = {
event.getCode match {
@ -45,33 +48,43 @@ class SendPaymentController(val handlers: Handlers, val stage: Stage) extends Lo
})
paymentRequest.textProperty.addListener(new ChangeListener[String] {
def changed(observable: ObservableValue[_ <: String], oldValue: String, newValue: String) = {
clearErrors()
Try(PaymentRequest.read(paymentRequest.getText)) match {
case Success(pr) =>
pr.amount.foreach(amount => amountField.setText(amount.amount.toString))
pr.description match {
case Left(s) => descriptionField.setText(s)
case Right(hash) =>
descriptionLabel.setText("Description's Hash")
descriptionField.setText(hash.toString())
}
nodeIdField.setText(pr.nodeId.toString)
hashField.setText(pr.paymentHash.toString)
paymentHashField.setText(pr.paymentHash.toString)
case Failure(f) =>
GUIValidators.validate(paymentRequestError, "Please use a valid payment request", false)
amountField.setText("0")
nodeIdField.setText("N/A")
hashField.setText("N/A")
paymentRequestError.setText("Could not read this payment request")
}
}
})
}
@FXML def handleSend(event: ActionEvent) = {
Try(PaymentRequest.read(paymentRequest.getText)) match {
case Success(pr) =>
Try(handlers.send(pr.nodeId, pr.paymentHash, pr.amount.get.amount, pr.minFinalCltvExpiry)) match {
(Try(MilliSatoshi(amountField.getText().toLong)), Try(PaymentRequest.read(paymentRequest.getText))) match {
case (Success(amountMsat), Success(pr)) =>
Try(handlers.send(pr.nodeId, pr.paymentHash, amountMsat.amount, pr.minFinalCltvExpiry)) match {
case Success(s) => stage.close
case Failure(f) => GUIValidators.validate(paymentRequestError, s"Invalid Payment Request: ${f.getMessage}", false)
case Failure(f) => paymentRequestError.setText(s"Invalid Payment Request: ${f.getMessage}")
}
case Failure(f) => GUIValidators.validate(paymentRequestError, "cannot parse payment request", false)
case (_, Success(_)) => amountFieldError.setText("Invalid amount")
case (_, Failure(f)) => paymentRequestError.setText("Could not read this payment request")
}
}
@FXML def handleClose(event: ActionEvent) = {
stage.close
}
private def clearErrors(): Unit = {
paymentRequestError.setText("")
amountFieldError.setText("")
}
}

View file

@ -19,8 +19,8 @@ class SendPaymentStage(handlers: Handlers) extends Stage() with Logging {
setTitle("Send a Payment Request")
setMinWidth(450)
setWidth(450)
setMinHeight(450)
setHeight(450)
setMinHeight(550)
setHeight(550)
// get fxml/controller
val receivePayment = new FXMLLoader(getClass.getResource("/gui/modals/sendPayment.fxml"))

View file

@ -1,15 +0,0 @@
package fr.acinq.eclair.gui.utils
import java.text.DecimalFormat
object CoinFormat {
/**
* Always 5 decimals
*/
val MILLI_BTC_PATTERN = "###,##0.00000"
/**
* Localized formatter for milli-bitcoin amounts. Uses `MILLI_BTC_PATTERN`.
*/
val MILLI_BTC_FORMAT: DecimalFormat = new DecimalFormat(MILLI_BTC_PATTERN)
}

View file

@ -0,0 +1,47 @@
package fr.acinq.eclair.gui.utils
import java.text.DecimalFormat
import fr.acinq.bitcoin.{MilliSatoshi, Satoshi}
import grizzled.slf4j.Logging
import fr.acinq.bitcoin._
object CoinUtils extends Logging {
/**
* Always 5 decimals
*/
val MILLI_BTC_PATTERN = "###,##0.00000"
/**
* Localized formatter for milli-bitcoin amounts. Uses `MILLI_BTC_PATTERN`.
*/
val MILLI_BTC_FORMAT: DecimalFormat = new DecimalFormat(MILLI_BTC_PATTERN)
val MILLI_SATOSHI_LABEL = "milliSatoshi"
val SATOSHI_LABEL = "satoshi"
val MILLI_BTC_LABEL = "milliBTC"
/**
* Converts a string amount denominated in a bitcoin unit to a Millisatoshi amount. The amount might be truncated if
* it has too many decimals because MilliSatoshi only accepts Long amount.
*
* @param amount numeric String, can be decimal.
* @param unit bitcoin unit, can be milliSatoshi, Satoshi or milliBTC.
* @return amount as a MilliSatoshi object.
* @throws NumberFormatException if the amount parameter is not numeric.
* @throws IllegalArgumentException if the unit is not equals to milliSatoshi, Satoshi or milliBTC.
*/
def convertStringAmountToMsat(amount: String, unit: String): MilliSatoshi = {
val amountDecimal = BigDecimal(amount)
logger.debug(s"amount=$amountDecimal with unit=$unit")
unit match {
case MILLI_SATOSHI_LABEL => MilliSatoshi(amountDecimal.longValue())
case SATOSHI_LABEL => MilliSatoshi((amountDecimal * 1000).longValue())
case MILLI_BTC_LABEL => MilliSatoshi((amountDecimal * 1000 * 100000).longValue())
case _ => throw new IllegalArgumentException("unknown unit")
}
}
def convertStringAmountToSat(amount: String, unit: String): Satoshi =
millisatoshi2satoshi(CoinUtils.convertStringAmountToMsat(amount, unit))
}

View file

@ -10,7 +10,7 @@ import scala.util.matching.Regex
object GUIValidators {
val hostRegex = """([a-fA-F0-9]{66})@([a-zA-Z0-9:\.\-_]+):([0-9]+)""".r
val amountRegex = """\d+""".r
val amountDecRegex = """(\d+)|(\d+\.[\d]{1,3})""".r // accepts 3 decimals at most
val amountDecRegex = """(\d+)|(\d+\.[\d]{1,})""".r
val paymentRequestRegex =
"""([a-zA-Z0-9]+):([a-zA-Z0-9]+):([a-zA-Z0-9]+)""".r
val hexRegex = """[0-9a-fA-F]+""".r