mirror of
https://github.com/ACINQ/eclair.git
synced 2024-11-19 18:10:42 +01:00
Added support for BOLT 11 payment request (#102)
* implement BOLT 11 (payment request) * (javafx) Added QR Code in receive payment modal, using zxing to generate QR code
This commit is contained in:
parent
7427cdb27b
commit
e7833055bd
@ -99,8 +99,8 @@ trait Service extends Logging {
|
||||
getChannel(channelId).flatMap(_ ? CMD_GETINFO).mapTo[RES_GETINFO]
|
||||
case JsonRPCBody(_, _, "network", _) =>
|
||||
(router ? 'nodes).mapTo[Iterable[NodeAnnouncement]].map(_.map(_.nodeId))
|
||||
case JsonRPCBody(_,_, "receive", JInt(amountMsat) :: Nil) =>
|
||||
(paymentHandler ? ReceivePayment(new MilliSatoshi(amountMsat.toLong))).mapTo[PaymentRequest].map(PaymentRequest.write(_))
|
||||
case JsonRPCBody(_,_, "receive", JInt(amountMsat) :: JString(description) :: Nil) =>
|
||||
(paymentHandler ? ReceivePayment(new 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(_, _, "close", JString(channelId) :: JString(scriptPubKey) :: Nil) =>
|
||||
|
@ -0,0 +1,158 @@
|
||||
package fr.acinq.eclair.crypto
|
||||
|
||||
import fr.acinq.bitcoin.BinaryData
|
||||
import org.spongycastle.util.encoders.Hex
|
||||
|
||||
import scala.annotation.tailrec
|
||||
|
||||
/**
|
||||
* Bit stream that can be written to and read at both ends (i.e. you can read from the end or the beginning of the stream)
|
||||
* @param bytes bits packed as bytes, the last byte is padded with 0s
|
||||
* @param offstart offset at which the first bit is in the first byte
|
||||
* @param offend offset at which the last bit is in the last byte
|
||||
*/
|
||||
case class BitStream(bytes: Vector[Byte], offstart: Int, offend: Int) {
|
||||
|
||||
// offstart: 0 1 2 3 4 5 6 7
|
||||
// offend: 7 6 5 4 3 2 1 0
|
||||
import BitStream._
|
||||
|
||||
def bitCount = 8 * bytes.length - offstart - offend
|
||||
|
||||
def isEmpty = bitCount == 0
|
||||
/**
|
||||
* append a byte to a bitstream
|
||||
*
|
||||
* @param input byte to append
|
||||
* @return an updated bitstream
|
||||
*/
|
||||
def writeByte(input: Byte): BitStream = offend match {
|
||||
case 0 => this.copy(bytes = this.bytes :+ input)
|
||||
case shift =>
|
||||
val input1 = input & 0xff
|
||||
val last = ((bytes.last | (input1 >>> (8 - shift))) & 0xff).toByte
|
||||
val next = ((input1 << shift) & 0xff).toByte
|
||||
this.copy(bytes = bytes.dropRight(1) ++ Vector(last, next))
|
||||
}
|
||||
|
||||
/**
|
||||
* append bytes to a bitstream
|
||||
*
|
||||
* @param input bytes to append
|
||||
* @return an udpdate bitstream
|
||||
*/
|
||||
def writeBytes(input: Seq[Byte]): BitStream = input.foldLeft(this) { case (bs, b) => bs.writeByte(b) }
|
||||
|
||||
/**
|
||||
* append a bit to a bistream
|
||||
*
|
||||
* @param bit bit to append
|
||||
* @return an update bitstream
|
||||
*/
|
||||
def writeBit(bit: Bit): BitStream = offend match {
|
||||
case 0 if bit =>
|
||||
BitStream(bytes :+ 0x80.toByte, offstart, 7)
|
||||
case 0 =>
|
||||
BitStream(bytes :+ 0x00.toByte, offstart, 7)
|
||||
case n if bit =>
|
||||
val last = (bytes.last + (1 << (offend - 1))).toByte
|
||||
BitStream(bytes.updated(bytes.length - 1, last), offstart, offend - 1)
|
||||
case n =>
|
||||
BitStream(bytes, offstart, offend - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* append bits to a bistream
|
||||
*
|
||||
* @param input bits to append
|
||||
* @return an update bitstream
|
||||
*/
|
||||
def writeBits(input: Seq[Bit]): BitStream = input.foldLeft(this) { case (bs, b) => bs.writeBit(b) }
|
||||
|
||||
/**
|
||||
* read the last bit from a bitstream
|
||||
*
|
||||
* @return a (stream, bit) pair where stream is an updated bitstream and bit is the last bit
|
||||
*/
|
||||
def popBit: (BitStream, Bit) = offend match {
|
||||
case 7 => BitStream(bytes.dropRight(1), offstart, 0) -> lastBit
|
||||
case n =>
|
||||
val shift = n + 1
|
||||
val last = (bytes.last >>> shift) << shift
|
||||
BitStream(bytes.updated(bytes.length - 1, last.toByte), offstart, offend + 1) -> lastBit
|
||||
}
|
||||
|
||||
/**
|
||||
* read the last byte from a bitstream
|
||||
*
|
||||
* @return a (stream, byte) pair where stream is an updated bitstream and byte is the last byte
|
||||
*/
|
||||
def popByte: (BitStream, Byte) = offend match {
|
||||
case 0 => BitStream(bytes.dropRight(1), offstart, offend) -> bytes.last
|
||||
case shift =>
|
||||
val a = bytes(bytes.length - 2) & 0xff
|
||||
val b = bytes(bytes.length - 1) & 0xff
|
||||
val byte = ((a << (8 - shift)) | (b >>> shift)) & 0xff
|
||||
val a1 = (a >>> shift) << shift
|
||||
BitStream(bytes.dropRight(2) :+ a1.toByte, offstart, offend) -> byte.toByte
|
||||
}
|
||||
|
||||
def popBytes(n: Int) : (BitStream, Seq[Byte]) = {
|
||||
@tailrec
|
||||
def loop(stream: BitStream, acc: Seq[Byte]) : (BitStream, Seq[Byte]) =
|
||||
if (acc.length == n) (stream, acc) else {
|
||||
val (stream1, value) = stream.popByte
|
||||
loop(stream1, acc :+ value)
|
||||
}
|
||||
|
||||
loop(this, Nil)
|
||||
}
|
||||
/**
|
||||
* read the first bit from a bitstream
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
def readBit: (BitStream, Bit) = offstart match {
|
||||
case 7 => BitStream(bytes.tail, 0, offend) -> firstBit
|
||||
case _ => BitStream(bytes, offstart + 1, offend) -> firstBit
|
||||
}
|
||||
|
||||
def readBits(count: Int) : (BitStream, Seq[Bit]) = {
|
||||
@tailrec
|
||||
def loop(stream: BitStream, acc: Seq[Bit]): (BitStream, Seq[Bit]) = if (acc.length == count) (stream, acc) else {
|
||||
val (stream1, bit) = stream.readBit
|
||||
loop(stream1, acc :+ bit)
|
||||
}
|
||||
|
||||
loop(this, Nil)
|
||||
}
|
||||
/**
|
||||
* read the first byte from a bitstream
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
def readByte: (BitStream, Byte) = {
|
||||
val byte = ((bytes(0) << offstart) | (bytes(1) >>> (7 - offstart))) & 0xff
|
||||
BitStream(bytes.tail, offstart, offend) -> byte.toByte
|
||||
}
|
||||
|
||||
def isSet(pos: Int): Boolean = {
|
||||
val pos1 = pos + offstart
|
||||
(bytes(pos1 / 8) & (1 << (7 - (pos1 % 8)))) != 0
|
||||
}
|
||||
|
||||
def firstBit = (bytes.head & (1 << (7 - offstart))) != 0
|
||||
|
||||
def lastBit = (bytes.last & (1 << offend)) != 0
|
||||
|
||||
def toBinString: String = "0b" + (for (i <- 0 until bitCount) yield if (isSet(i)) '1' else '0').mkString
|
||||
|
||||
def toHexString: String = "0x" + Hex.toHexString(bytes.toArray).toLowerCase
|
||||
}
|
||||
|
||||
object BitStream {
|
||||
type Bit = Boolean
|
||||
val Zero = false
|
||||
val One = true
|
||||
val empty = BitStream(Vector.empty[Byte], 0, 0)
|
||||
}
|
@ -17,11 +17,11 @@ class LocalPaymentHandler(nodeParams: NodeParams) extends Actor with ActorLoggin
|
||||
|
||||
def run(h2r: Map[BinaryData, (BinaryData, PaymentRequest)]): Receive = {
|
||||
|
||||
case ReceivePayment(amount) =>
|
||||
case ReceivePayment(amount, desc) =>
|
||||
Try {
|
||||
val r = randomBytes(32)
|
||||
val h = Crypto.sha256(r)
|
||||
(r, h, new PaymentRequest(nodeParams.privateKey.publicKey, amount, h))
|
||||
val paymentPreimage = randomBytes(32)
|
||||
val paymentHash = Crypto.sha256(paymentPreimage)
|
||||
(paymentPreimage, paymentHash, PaymentRequest(nodeParams.chainHash, Some(amount), paymentHash, nodeParams.privateKey, desc))
|
||||
} match {
|
||||
case Success((r, h, pr)) =>
|
||||
log.debug(s"generated payment request=${PaymentRequest.write(pr)} from amount=$amount")
|
||||
@ -38,16 +38,18 @@ class LocalPaymentHandler(nodeParams: NodeParams) extends Actor with ActorLoggin
|
||||
// The htlc amount must be equal or greater than the requested amount. A slight overpaying is permitted, however
|
||||
// it must not be greater than two times the requested amount.
|
||||
// see https://github.com/lightningnetwork/lightning-rfc/blob/master/04-onion-routing.md#failure-messages
|
||||
if (pr.amount.amount <= htlc.amountMsat && htlc.amountMsat <= (2 * pr.amount.amount)) {
|
||||
sender ! CMD_FULFILL_HTLC(htlc.id, r, commit = true)
|
||||
context.system.eventStream.publish(PaymentReceived(MilliSatoshi(htlc.amountMsat), htlc.paymentHash))
|
||||
context.become(run(h2r - htlc.paymentHash))
|
||||
} else {
|
||||
sender ! CMD_FAIL_HTLC(htlc.id, Right(IncorrectPaymentAmount), commit = true)
|
||||
}
|
||||
} else {
|
||||
pr.amount match {
|
||||
case Some(amount) if MilliSatoshi(htlc.amountMsat) < amount => sender ! CMD_FAIL_HTLC(htlc.id, Right(IncorrectPaymentAmount), commit = true)
|
||||
case Some(amount) if MilliSatoshi(htlc.amountMsat) > amount * 2 => sender ! CMD_FAIL_HTLC(htlc.id, Right(IncorrectPaymentAmount), commit = true)
|
||||
case _ =>
|
||||
// amount is correct or was not specified in the payment request
|
||||
sender ! CMD_FULFILL_HTLC(htlc.id, r, commit = true)
|
||||
context.system.eventStream.publish(PaymentReceived(MilliSatoshi(htlc.amountMsat), htlc.paymentHash))
|
||||
context.become(run(h2r - htlc.paymentHash))
|
||||
}
|
||||
} else {
|
||||
sender ! CMD_FAIL_HTLC(htlc.id, Right(UnknownPaymentHash), commit = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -13,7 +13,7 @@ import fr.acinq.eclair.wire._
|
||||
import scodec.Attempt
|
||||
|
||||
// @formatter:off
|
||||
case class ReceivePayment(amountMsat: MilliSatoshi)
|
||||
case class ReceivePayment(amountMsat: MilliSatoshi, description: String)
|
||||
case class SendPayment(amountMsat: Long, paymentHash: BinaryData, targetNodeId: PublicKey, maxAttempts: Int = 5)
|
||||
|
||||
sealed trait PaymentResult
|
||||
|
@ -1,43 +1,417 @@
|
||||
package fr.acinq.eclair.payment
|
||||
|
||||
import fr.acinq.bitcoin.{BinaryData, MilliSatoshi}
|
||||
import grizzled.slf4j.Logging
|
||||
import java.math.BigInteger
|
||||
import java.nio.ByteOrder
|
||||
|
||||
import scala.util.{Failure, Success, Try}
|
||||
import fr.acinq.bitcoin.Bech32.Int5
|
||||
import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey}
|
||||
import fr.acinq.bitcoin.{BinaryData, MilliSatoshi, _}
|
||||
import fr.acinq.eclair.crypto.BitStream
|
||||
import fr.acinq.eclair.crypto.BitStream.Bit
|
||||
import fr.acinq.eclair.payment.PaymentRequest.{Amount, RoutingInfoTag, Timestamp}
|
||||
|
||||
import scala.annotation.tailrec
|
||||
import scala.util.Try
|
||||
|
||||
/**
|
||||
* Created by DPA on 11/04/2017.
|
||||
* Lightning Payment Request
|
||||
* see https://github.com/lightningnetwork/lightning-rfc/pull/183
|
||||
*
|
||||
* @param prefix currency prefix; lnbc for bitcoin, lntb for bitcoin testnet
|
||||
* @param amount amount to pay (empty string means no amount is specified)
|
||||
* @param timestamp request timestamp (UNIX format)
|
||||
* @param nodeId id of the node emitting the payment request
|
||||
* @param tags payment tags; must include a single PaymentHash tag
|
||||
* @param signature request signature that will be checked against node id
|
||||
*/
|
||||
object PaymentRequest extends Logging {
|
||||
case class PaymentRequest(prefix: String, amount: Option[MilliSatoshi], timestamp: Long, nodeId: PublicKey, tags: List[PaymentRequest.Tag], signature: BinaryData) {
|
||||
|
||||
// https://github.com/lightningnetwork/lightning-rfc/blob/master/02-peer-protocol.md#adding-an-htlc-update_add_htlc
|
||||
val maxAmountMsat = 4294967296L
|
||||
amount.map(a => require(a > MilliSatoshi(0) && a <= PaymentRequest.maxAmount, 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")
|
||||
|
||||
def write(pr: PaymentRequest): String = {
|
||||
s"${pr.nodeId.toString}:${pr.amount.amount}:${pr.paymentHash.toString}"
|
||||
/**
|
||||
*
|
||||
* @return the payment hash
|
||||
*/
|
||||
def paymentHash = tags.collectFirst { case p: PaymentRequest.PaymentHashTag => p }.get.hash
|
||||
|
||||
/**
|
||||
*
|
||||
* @return the description of the payment, or its hash
|
||||
*/
|
||||
def description: Either[String, BinaryData] = tags.collectFirst {
|
||||
case PaymentRequest.DescriptionTag(d) => Left(d)
|
||||
case PaymentRequest.DescriptionHashTag(h) => Right(h)
|
||||
}.get
|
||||
|
||||
/**
|
||||
*
|
||||
* @return the fallback address if any. It could be a script address, pubkey address, ..
|
||||
*/
|
||||
def fallbackAddress(): Option[String] = tags.collectFirst {
|
||||
case PaymentRequest.FallbackAddressTag(17, hash) if prefix == "lnbc" => Base58Check.encode(Base58.Prefix.PubkeyAddress, hash)
|
||||
case PaymentRequest.FallbackAddressTag(18, hash) if prefix == "lnbc" => Base58Check.encode(Base58.Prefix.ScriptAddress, hash)
|
||||
case PaymentRequest.FallbackAddressTag(17, hash) if prefix == "lntb" => Base58Check.encode(Base58.Prefix.PubkeyAddressTestnet, hash)
|
||||
case PaymentRequest.FallbackAddressTag(18, hash) if prefix == "lntb" => Base58Check.encode(Base58.Prefix.ScriptAddressTestnet, hash)
|
||||
case PaymentRequest.FallbackAddressTag(version, hash) if prefix == "lnbc" => Bech32.encodeWitnessAddress("bc", version, hash)
|
||||
case PaymentRequest.FallbackAddressTag(version, hash) if prefix == "lntb" => Bech32.encodeWitnessAddress("tb", version, hash)
|
||||
}
|
||||
|
||||
def routingInfo(): Seq[RoutingInfoTag] = tags.collect { case t: RoutingInfoTag => t}
|
||||
|
||||
/**
|
||||
*
|
||||
* @return a representation of this payment request, without its signature, as a bit stream. This is what will be signed.
|
||||
*/
|
||||
def stream: BitStream = {
|
||||
val stream = BitStream.empty
|
||||
val int5s = Timestamp.encode(timestamp) ++ (tags.map(_.toInt5s).flatten)
|
||||
val stream1 = int5s.foldLeft(stream)(PaymentRequest.write5)
|
||||
stream1
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string and if the string is correctly formatted, returns a PaymentRequest object.
|
||||
* Otherwise, throws and exception
|
||||
*
|
||||
* @param pr payment request string, should look like <pre>node:amount:hash</pre>
|
||||
* @return a PaymentRequest object
|
||||
* @return the hash of this payment request
|
||||
*/
|
||||
def read(pr: String): PaymentRequest = {
|
||||
Try {
|
||||
val Array(nodeId, amount, hash) = pr.split(":")
|
||||
PaymentRequest(BinaryData(nodeId), MilliSatoshi(amount.toLong), BinaryData(hash))
|
||||
} match {
|
||||
case Success(s) => s
|
||||
case Failure(t) =>
|
||||
logger.debug(s"could not parse payment request: ${t.getMessage}")
|
||||
throw t
|
||||
}
|
||||
def hash: BinaryData = Crypto.sha256(s"${prefix}${Amount.encode(amount)}".getBytes("UTF-8") ++ stream.bytes)
|
||||
|
||||
/**
|
||||
*
|
||||
* @param priv private key
|
||||
* @return a signed payment request
|
||||
*/
|
||||
def sign(priv: PrivateKey): PaymentRequest = {
|
||||
val (r, s) = Crypto.sign(hash, priv)
|
||||
val (pub1, pub2) = Crypto.recoverPublicKey((r, s), hash)
|
||||
val recid = if (nodeId == pub1) 0.toByte else 1.toByte
|
||||
val signature = PaymentRequest.Signature.encode(r, s, recid)
|
||||
this.copy(signature = signature)
|
||||
}
|
||||
}
|
||||
|
||||
case class PaymentRequest(nodeId: BinaryData, amount: MilliSatoshi, paymentHash: BinaryData) {
|
||||
require(amount.amount > 0 && amount.amount < PaymentRequest.maxAmountMsat,
|
||||
f"amount is not valid: must be > 0 and < ${PaymentRequest.maxAmountMsat}%,d msat (~${PaymentRequest.maxAmountMsat / 1e11}%.3f BTC)")
|
||||
object PaymentRequest {
|
||||
|
||||
// https://github.com/lightningnetwork/lightning-rfc/blob/master/02-peer-protocol.md#adding-an-htlc-update_add_htlc
|
||||
val maxAmount = MilliSatoshi(4294967296L)
|
||||
|
||||
def apply(chainHash: BinaryData, amount: Option[MilliSatoshi], paymentHash: BinaryData, privateKey: PrivateKey, description: String, fallbackAddress: Option[String] = None, expirySeconds: Option[Long] = None, timestamp: Long = System.currentTimeMillis() / 1000L): PaymentRequest = {
|
||||
val prefix = chainHash match {
|
||||
case Block.RegtestGenesisBlock.blockId => "lntb"
|
||||
case Block.TestnetGenesisBlock.blockId => "lntb"
|
||||
case Block.LivenetGenesisBlock.blockId => "lnbc"
|
||||
}
|
||||
PaymentRequest(
|
||||
prefix = prefix,
|
||||
amount = amount,
|
||||
timestamp = timestamp,
|
||||
nodeId = privateKey.publicKey,
|
||||
tags = List(
|
||||
Some(PaymentHashTag(paymentHash)),
|
||||
Some(DescriptionTag(description)),
|
||||
expirySeconds.map(ExpiryTag(_))).flatten,
|
||||
signature = BinaryData.empty)
|
||||
.sign(privateKey)
|
||||
}
|
||||
|
||||
sealed trait Tag {
|
||||
def toInt5s: Seq[Int5]
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment Hash Tag
|
||||
*
|
||||
* @param hash payment hash
|
||||
*/
|
||||
case class PaymentHashTag(hash: BinaryData) extends Tag {
|
||||
override def toInt5s = {
|
||||
val ints = Bech32.eight2five(hash)
|
||||
Seq(Bech32.map('p'), (ints.length / 32).toByte, (ints.length % 32).toByte) ++ ints
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Description Tag
|
||||
*
|
||||
* @param description a free-format string that will be included in the payment request
|
||||
*/
|
||||
case class DescriptionTag(description: String) extends Tag {
|
||||
override def toInt5s = {
|
||||
val ints = Bech32.eight2five(description.getBytes("UTF-8"))
|
||||
Seq(Bech32.map('d'), (ints.length / 32).toByte, (ints.length % 32).toByte) ++ ints
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash Tag
|
||||
*
|
||||
* @param hash hash that will be included in the payment request, and can be checked against the hash of a
|
||||
* long description, an invoice, ...
|
||||
*/
|
||||
case class DescriptionHashTag(hash: BinaryData) extends Tag {
|
||||
override def toInt5s = {
|
||||
val ints = Bech32.eight2five(hash)
|
||||
Seq(Bech32.map('h'), (ints.length / 32).toByte, (ints.length % 32).toByte) ++ ints
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fallback Payment Tag that specifies a fallback payment address to be used if LN payment cannot be processed
|
||||
*
|
||||
* @param version address version; valid values are
|
||||
* - 17 (pubkey hash)
|
||||
* - 18 (script hash)
|
||||
* - 0 (segwit hash: p2wpkh (20 bytes) or p2wsh (32 bytes))
|
||||
* @param hash address hash
|
||||
*/
|
||||
case class FallbackAddressTag(version: Byte, hash: BinaryData) extends Tag {
|
||||
override def toInt5s = {
|
||||
val ints = version +: Bech32.eight2five(hash)
|
||||
Seq(Bech32.map('f'), (ints.length / 32).toByte, (ints.length % 32).toByte) ++ ints
|
||||
}
|
||||
}
|
||||
|
||||
object FallbackAddressTag {
|
||||
/**
|
||||
*
|
||||
* @param address valid base58 or bech32 address
|
||||
* @return a FallbackAddressTag instance
|
||||
*/
|
||||
def apply(address: String): FallbackAddressTag = {
|
||||
Try(fromBase58Address(address)).orElse(Try(fromBech32Address(address))).get
|
||||
}
|
||||
|
||||
def fromBase58Address(address: String): FallbackAddressTag = {
|
||||
val (prefix, hash) = Base58Check.decode(address)
|
||||
prefix match {
|
||||
case Base58.Prefix.PubkeyAddress => FallbackAddressTag(17, hash)
|
||||
case Base58.Prefix.PubkeyAddressTestnet => FallbackAddressTag(17, hash)
|
||||
case Base58.Prefix.ScriptAddress => FallbackAddressTag(18, hash)
|
||||
case Base58.Prefix.ScriptAddressTestnet => FallbackAddressTag(18, hash)
|
||||
}
|
||||
}
|
||||
|
||||
def fromBech32Address(address: String): FallbackAddressTag = {
|
||||
val (prefix, hash) = Bech32.decodeWitnessAddress(address)
|
||||
FallbackAddressTag(prefix, hash)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routing Info Tag
|
||||
*
|
||||
* @param pubkey node id
|
||||
* @param channelId channel id
|
||||
* @param fee node fee
|
||||
* @param cltvExpiryDelta node cltv expiry delta
|
||||
*/
|
||||
case class RoutingInfoTag(pubkey: PublicKey, channelId: BinaryData, fee: Long, cltvExpiryDelta: Int) extends Tag {
|
||||
override def toInt5s = {
|
||||
val ints = Bech32.eight2five(pubkey.toBin ++ channelId ++ Protocol.writeUInt64(fee, ByteOrder.BIG_ENDIAN) ++ Protocol.writeUInt16(cltvExpiryDelta, ByteOrder.BIG_ENDIAN))
|
||||
Seq(Bech32.map('r'), (ints.length / 32).toByte, (ints.length % 32).toByte) ++ ints
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expiry Date
|
||||
*
|
||||
* @param seconds expriry data for this payment request
|
||||
*/
|
||||
case class ExpiryTag(seconds: Long) extends Tag {
|
||||
override def toInt5s = {
|
||||
val ints = Seq((seconds / 32).toByte, (seconds % 32).toByte)
|
||||
Seq(Bech32.map('x'), 0.toByte, 2.toByte) ++ ints
|
||||
}
|
||||
}
|
||||
|
||||
object Amount {
|
||||
|
||||
/**
|
||||
* @param amount
|
||||
* @return the unit allowing for the shortest representation possible
|
||||
*/
|
||||
def unit(amount: MilliSatoshi): Char = amount.amount * 10 match { // 1 milli-satoshis == 10 pico-bitcoin
|
||||
case pico if pico % 1000 > 0 => 'p'
|
||||
case pico if pico % 1000000 > 0 => 'n'
|
||||
case pico if pico % 1000000000 > 0 => 'u'
|
||||
case _ => 'm'
|
||||
}
|
||||
|
||||
def decode(input: String): Option[MilliSatoshi] =
|
||||
input match {
|
||||
case "" => None
|
||||
case a if a.last == 'p' => Some(MilliSatoshi(a.dropRight(1).toLong / 10L)) // 1 pico-bitcoin == 10 milli-satoshis
|
||||
case a if a.last == 'n' => Some(MilliSatoshi(a.dropRight(1).toLong * 100L))
|
||||
case a if a.last == 'u' => Some(MilliSatoshi(a.dropRight(1).toLong * 100000L))
|
||||
case a if a.last == 'm' => Some(MilliSatoshi(a.dropRight(1).toLong * 100000000L))
|
||||
}
|
||||
|
||||
def encode(amount: Option[MilliSatoshi]): String = {
|
||||
amount match {
|
||||
case None => ""
|
||||
case Some(amt) if unit(amt) == 'p' => s"${amt.amount * 10L}p" // 1 pico-bitcoin == 10 milli-satoshis
|
||||
case Some(amt) if unit(amt) == 'n' => s"${amt.amount / 100L}n"
|
||||
case Some(amt) if unit(amt) == 'u' => s"${amt.amount / 100000L}u"
|
||||
case Some(amt) if unit(amt) == 'm' => s"${amt.amount / 100000000L}m"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Tag {
|
||||
def parse(input: Seq[Byte]): Tag = {
|
||||
val tag = input(0)
|
||||
val len = input(1) * 32 + input(2)
|
||||
tag match {
|
||||
case p if p == Bech32.map('p') =>
|
||||
val hash = Bech32.five2eight(input.drop(3).take(52))
|
||||
PaymentHashTag(hash)
|
||||
case d if d == Bech32.map('d') =>
|
||||
val description = new String(Bech32.five2eight(input.drop(3).take(len)).toArray, "UTF-8")
|
||||
DescriptionTag(description)
|
||||
case h if h == Bech32.map('h') =>
|
||||
val hash: BinaryData = Bech32.five2eight(input.drop(3).take(len))
|
||||
DescriptionHashTag(hash)
|
||||
case f if f == Bech32.map('f') =>
|
||||
val version = input(3)
|
||||
val prog = Bech32.five2eight(input.drop(4).take(len - 1))
|
||||
version match {
|
||||
case v if v >= 0 && v <= 16 =>
|
||||
FallbackAddressTag(version, prog)
|
||||
case 17 | 18 =>
|
||||
FallbackAddressTag(version, prog)
|
||||
}
|
||||
case r if r == Bech32.map('r') =>
|
||||
val data = Bech32.five2eight(input.drop(3).take(len))
|
||||
val pubkey = PublicKey(data.take(33))
|
||||
val channelId = data.drop(33).take(8)
|
||||
val fee = Protocol.uint64(data.drop(33 + 8), ByteOrder.BIG_ENDIAN)
|
||||
val cltv = Protocol.uint16(data.drop(33 + 8 + 8), ByteOrder.BIG_ENDIAN)
|
||||
RoutingInfoTag(pubkey, channelId, fee, cltv)
|
||||
case x if x == Bech32.map('x') =>
|
||||
require(len == 2, s"invalid length for expiry tag, should be 2 instead of $len")
|
||||
val expiry = 32 * input(3) + input(4)
|
||||
ExpiryTag(expiry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Timestamp {
|
||||
def decode(data: Seq[Int5]): Long = data.take(7).foldLeft(0L)((a, b) => a * 32 + b)
|
||||
|
||||
def encode(timestamp: Long, acc: Seq[Int5] = Nil): Seq[Int5] = if (acc.length == 7) acc else {
|
||||
encode(timestamp / 32, (timestamp % 32).toByte +: acc)
|
||||
}
|
||||
}
|
||||
|
||||
object Signature {
|
||||
/**
|
||||
*
|
||||
* @param signature 65-bytes signatyre: r (32 bytes) | s (32 bytes) | recid (1 bytes)
|
||||
* @return a (r, s, recoveryId)
|
||||
*/
|
||||
def decode(signature: BinaryData): (BigInteger, BigInteger, Byte) = {
|
||||
require(signature.length == 65)
|
||||
val r = new BigInteger(1, signature.take(32).toArray)
|
||||
val s = new BigInteger(1, signature.drop(32).take(32).toArray)
|
||||
val recid = signature.last
|
||||
(r, s, recid)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @return a 65 bytes representation of (r, s, recid)
|
||||
*/
|
||||
def encode(r: BigInteger, s: BigInteger, recid: Byte): BinaryData = {
|
||||
Crypto.fixSize(r.toByteArray.dropWhile(_ == 0.toByte)) ++ Crypto.fixSize(s.toByteArray.dropWhile(_ == 0.toByte)) :+ recid
|
||||
}
|
||||
}
|
||||
|
||||
def toBits(value: Int5) : Seq[Bit] = Seq((value & 16) != 0, (value & 8) != 0, (value & 4) != 0, (value & 2) != 0, (value & 1) != 0)
|
||||
|
||||
/**
|
||||
* write a 5bits integer to a stream
|
||||
* @param stream stream to write to
|
||||
* @param value a 5bits value
|
||||
* @return an upated stream
|
||||
*/
|
||||
def write5(stream: BitStream, value: Int5) : BitStream = stream.writeBits(toBits(value))
|
||||
|
||||
/**
|
||||
* read a 5bits value from a stream
|
||||
* @param stream stream to read from
|
||||
* @return a (stream, value) pair
|
||||
*/
|
||||
def read5(stream: BitStream) : (BitStream, Int5) = {
|
||||
val (stream1, bits) = stream.readBits(5)
|
||||
val value = (if (bits(0)) 1 << 4 else 0) + (if (bits(1)) 1 << 3 else 0) + (if (bits(2)) 1 << 2 else 0) + (if (bits(3)) 1 << 1 else 0) + (if (bits(4)) 1 << 0 else 0)
|
||||
(stream1, (value & 0xff).toByte)
|
||||
}
|
||||
|
||||
/**
|
||||
* splits a bit stream into 5bits values
|
||||
* @param stream
|
||||
* @param acc
|
||||
* @return a sequence of 5bits values
|
||||
*/
|
||||
@tailrec
|
||||
def toInt5s(stream: BitStream, acc :Seq[Int5] = Nil) : Seq[Int5] = if (stream.bitCount == 0) acc else {
|
||||
val (stream1, value) = read5(stream)
|
||||
toInt5s(stream1, acc :+ value)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param input bech32-encoded payment request
|
||||
* @return a payment request
|
||||
*/
|
||||
def read(input: String): PaymentRequest = {
|
||||
val (hrp, data) = Bech32.decode(input)
|
||||
val stream = data.foldLeft(BitStream.empty)(write5)
|
||||
require(stream.bitCount >= 65 * 8, "data is too short to contain a 65 bytes signature")
|
||||
val (stream1, sig) = stream.popBytes(65)
|
||||
|
||||
val data0 = toInt5s(stream1)
|
||||
val timestamp = Timestamp.decode(data0)
|
||||
val data1 = data0.drop(7)
|
||||
|
||||
@tailrec
|
||||
def loop(data: Seq[Int5], tags: Seq[Seq[Int5]] = Nil): Seq[Seq[Int5]] = if(data.isEmpty) tags else {
|
||||
// 104 is the size of a signature
|
||||
val len = 1 + 2 + 32 * data(1) + data(2)
|
||||
loop(data.drop(len), tags :+ data.take(len))
|
||||
}
|
||||
|
||||
val rawtags = loop(data1)
|
||||
val tags = rawtags.map(Tag.parse)
|
||||
val signature = sig.reverse
|
||||
val r = new BigInteger(1, signature.take(32).toArray)
|
||||
val s = new BigInteger(1, signature.drop(32).take(32).toArray)
|
||||
val recid = signature.last
|
||||
val message: BinaryData = hrp.getBytes ++ stream1.bytes
|
||||
val (pub1, pub2) = Crypto.recoverPublicKey((r, s), Crypto.sha256(message))
|
||||
val pub = if (recid % 2 != 0) pub2 else pub1
|
||||
val prefix = hrp.take(4)
|
||||
val amount_opt = Amount.decode(hrp.drop(4))
|
||||
val pr = PaymentRequest(prefix, amount_opt, timestamp, pub, tags.toList, signature)
|
||||
val validSig = Crypto.verifySignature(Crypto.sha256(message), (r, s), pub)
|
||||
require(validSig, "invalid signature")
|
||||
pr
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param pr payment request
|
||||
* @return a bech32-encoded payment request
|
||||
*/
|
||||
def write(pr: PaymentRequest): String = {
|
||||
// currency unit is Satoshi, but we compute amounts in Millisatoshis
|
||||
val hramount = Amount.encode(pr.amount)
|
||||
val hrp = s"${pr.prefix}$hramount"
|
||||
val stream = pr.stream.writeBytes(pr.signature)
|
||||
val checksum = Bech32.checksum(hrp, toInt5s(stream))
|
||||
hrp + "1" + new String((toInt5s(stream) ++ checksum).map(i => Bech32.pam(i)).toArray)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,7 +87,7 @@ class FuzzySpec extends TestkitBaseClass with StateTestsHelperMethods {
|
||||
// we don't want to be above maxHtlcValueInFlightMsat or maxAcceptedHtlcs
|
||||
awaitCond(channel.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.htlcs.size < 10 && channel.stateData.asInstanceOf[DATA_NORMAL].commitments.remoteCommit.spec.htlcs.size < 10)
|
||||
val senders = for (i <- 0 until parallel) yield TestProbe()
|
||||
senders.foreach(_.send(paymentHandler, ReceivePayment(MilliSatoshi(requiredAmount))))
|
||||
senders.foreach(_.send(paymentHandler, ReceivePayment(MilliSatoshi(requiredAmount), "One coffee")))
|
||||
val paymentHashes = senders.map(_.expectMsgType[PaymentRequest]).map(pr => pr.paymentHash)
|
||||
val cmds = paymentHashes.map(h => buildCmdAdd(h, destination))
|
||||
senders.zip(cmds).foreach {
|
||||
|
@ -0,0 +1,74 @@
|
||||
package fr.acinq.eclair.crypto
|
||||
|
||||
import org.junit.runner.RunWith
|
||||
import org.scalatest.FunSuite
|
||||
import org.scalatest.junit.JUnitRunner
|
||||
import org.spongycastle.util.encoders.Hex
|
||||
|
||||
/**
|
||||
* Created by fabrice on 22/06/17.
|
||||
*/
|
||||
@RunWith(classOf[JUnitRunner])
|
||||
class BitStreamSpec extends FunSuite {
|
||||
import BitStream._
|
||||
|
||||
test("add bits") {
|
||||
val bits = BitStream.empty
|
||||
val bits1 = bits.writeBit(One)
|
||||
assert(bits1.bitCount == 1)
|
||||
assert(bits1.isSet(0))
|
||||
assert(Hex.toHexString(bits1.bytes.toArray) == "80")
|
||||
val bits2 = bits1.writeBit(Zero)
|
||||
assert(bits2.bitCount == 2)
|
||||
assert(bits2.isSet(0))
|
||||
assert(!bits2.isSet(1))
|
||||
assert(Hex.toHexString(bits2.bytes.toArray) == "80")
|
||||
val bits3 = bits2.writeBit(One)
|
||||
assert(bits3.bitCount == 3)
|
||||
assert(bits3.isSet(0))
|
||||
assert(!bits3.isSet(1))
|
||||
assert(bits3.isSet(2))
|
||||
assert(bits3.toHexString == "0xa0")
|
||||
assert(bits3.toBinString == "0b101")
|
||||
|
||||
val (bits4, One) = bits3.popBit
|
||||
assert(bits4 == bits2)
|
||||
val (bits5, Zero) = bits4.popBit
|
||||
assert(bits5 == bits1)
|
||||
val (bits6, One) = bits5.popBit
|
||||
assert(bits6 == bits)
|
||||
|
||||
val (bits7, One) = bits3.readBit
|
||||
val (bits8, Zero) = bits7.readBit
|
||||
val (bits9, One) = bits8.readBit
|
||||
assert(bits9.isEmpty)
|
||||
}
|
||||
|
||||
test("add bytes") {
|
||||
val bits = BitStream.empty
|
||||
val bits1 = bits.writeByte(0xb5.toByte)
|
||||
assert(bits1.bitCount == 8)
|
||||
assert(bits1.toHexString == "0xb5")
|
||||
assert(bits1.toBinString == "0b10110101")
|
||||
// b5 = 1100 0101
|
||||
val bits2 = bits1.writeBit(Zero)
|
||||
assert(bits2.bitCount == 9)
|
||||
// 1100 0101 0
|
||||
assert(bits2.toHexString == "0xb500")
|
||||
assert(bits2.toBinString == "0b101101010")
|
||||
val bits3 = bits2.writeBit(One)
|
||||
assert(bits3.bitCount == 10)
|
||||
// 1100 0101 01
|
||||
assert(bits3.toHexString == "0xb540")
|
||||
assert(bits3.toBinString == "0b1011010101")
|
||||
|
||||
// 1011 0101 01xx xxxx
|
||||
// 10xx xxxx and 1101 0101
|
||||
val (bits4, check) = bits3.popByte
|
||||
assert(check == 0xd5.toByte)
|
||||
assert(bits4.toBinString == "0b10")
|
||||
|
||||
val (bits5, check5) = bits3.readByte
|
||||
assert(check5 == 0xb5.toByte)
|
||||
}
|
||||
}
|
@ -253,7 +253,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))
|
||||
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
|
||||
val pr = sender.expectMsgType[PaymentRequest]
|
||||
// then we make the actual payment
|
||||
sender.send(nodes("A").paymentInitiator,
|
||||
@ -271,7 +271,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))
|
||||
sender.send(nodes("D").paymentHandler, ReceivePayment(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)
|
||||
@ -290,7 +290,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))
|
||||
sender.send(nodes("D").paymentHandler, ReceivePayment(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)
|
||||
@ -312,7 +312,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))
|
||||
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
|
||||
val pr = sender.expectMsgType[PaymentRequest]
|
||||
|
||||
// A send payment of only 1 mBTC
|
||||
@ -327,7 +327,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))
|
||||
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
|
||||
val pr = sender.expectMsgType[PaymentRequest]
|
||||
|
||||
// A send payment of 6 mBTC
|
||||
@ -342,7 +342,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))
|
||||
sender.send(nodes("D").paymentHandler, ReceivePayment(amountMsat, "1 coffee"))
|
||||
val pr = sender.expectMsgType[PaymentRequest]
|
||||
|
||||
// A send payment of 3 mBTC, more than asked but it should still be accepted
|
||||
|
@ -3,7 +3,7 @@ package fr.acinq.eclair.payment
|
||||
import akka.actor.ActorSystem
|
||||
import akka.actor.Status.Failure
|
||||
import akka.testkit.{TestKit, TestProbe}
|
||||
import fr.acinq.bitcoin.MilliSatoshi
|
||||
import fr.acinq.bitcoin.{MilliSatoshi, Satoshi}
|
||||
import fr.acinq.eclair.TestConstants.Alice
|
||||
import fr.acinq.eclair.channel.CMD_FULFILL_HTLC
|
||||
import fr.acinq.eclair.wire.UpdateAddHtlc
|
||||
@ -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))
|
||||
sender.send(handler, ReceivePayment(amountMsat, "1 coffee"))
|
||||
val pr = sender.expectMsgType[PaymentRequest]
|
||||
|
||||
val add = UpdateAddHtlc("11" * 32, 0, amountMsat.amount, 0, pr.paymentHash, "")
|
||||
@ -40,24 +40,23 @@ 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)))
|
||||
sender.send(handler, ReceivePayment(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)))
|
||||
sender.send(handler, ReceivePayment(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(MilliSatoshi(PaymentRequest.maxAmountMsat + 10)))
|
||||
sender.send(handler, ReceivePayment(Satoshi(1) + PaymentRequest.maxAmount, "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)))
|
||||
sender.send(handler, ReceivePayment(MilliSatoshi(100000000L), "1 coffee"))
|
||||
val pr = sender.expectMsgType[PaymentRequest]
|
||||
assert(pr.amount.amount == 100000000L
|
||||
&& pr.nodeId.toString == Alice.nodeParams.privateKey.publicKey.toString)
|
||||
assert(pr.amount == Some(MilliSatoshi(100000000L)) && pr.nodeId.toString == Alice.nodeParams.privateKey.publicKey.toString)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,157 @@
|
||||
package fr.acinq.eclair.payment
|
||||
|
||||
import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey}
|
||||
import fr.acinq.bitcoin.{BinaryData, Block, Btc, Crypto, MilliBtc, MilliSatoshi, Satoshi}
|
||||
import fr.acinq.eclair.payment.PaymentRequest.DescriptionTag
|
||||
import org.junit.runner.RunWith
|
||||
import org.scalatest.FunSuite
|
||||
import org.scalatest.junit.JUnitRunner
|
||||
|
||||
/**
|
||||
* Created by fabrice on 15/05/17.
|
||||
*/
|
||||
@RunWith(classOf[JUnitRunner])
|
||||
class PaymentRequestSpec extends FunSuite {
|
||||
|
||||
import PaymentRequest._
|
||||
|
||||
val priv = PrivateKey(BinaryData("e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734"), compressed = true)
|
||||
val pub = priv.publicKey
|
||||
val nodeId = pub
|
||||
assert(nodeId == PublicKey(BinaryData("03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad")))
|
||||
|
||||
test("check minimal unit is used") {
|
||||
assert('p' === Amount.unit(MilliSatoshi(1)))
|
||||
assert('p' === Amount.unit(MilliSatoshi(99)))
|
||||
assert('n' === Amount.unit(MilliSatoshi(100)))
|
||||
assert('p' === Amount.unit(MilliSatoshi(101)))
|
||||
assert('n' === Amount.unit(Satoshi(1)))
|
||||
assert('u' === Amount.unit(Satoshi(100)))
|
||||
assert('n' === Amount.unit(Satoshi(101)))
|
||||
assert('u' === Amount.unit(Satoshi(1155400)))
|
||||
assert('m' === Amount.unit(MilliBtc(1)))
|
||||
assert('m' === Amount.unit(MilliBtc(10)))
|
||||
assert('m' === Amount.unit(Btc(1)))
|
||||
}
|
||||
|
||||
test("check that we can still decode non-minimal amount encoding") {
|
||||
assert(Some(MilliSatoshi(100000000)) == Amount.decode("1000u"))
|
||||
assert(Some(MilliSatoshi(100000000)) == Amount.decode("1000000n"))
|
||||
assert(Some(MilliSatoshi(100000000)) == Amount.decode("1000000000p"))
|
||||
}
|
||||
|
||||
test("Please make a donation of any amount using payment_hash 0001020304050607080900010203040506070809000102030405060708090102 to me @03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad") {
|
||||
val ref = "lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq8rkx3yf5tcsyz3d73gafnh3cax9rn449d9p5uxz9ezhhypd0elx87sjle52x86fux2ypatgddc6k63n7erqz25le42c4u4ecky03ylcqca784w"
|
||||
val pr = PaymentRequest.read(ref)
|
||||
assert(pr.prefix == "lnbc")
|
||||
assert(pr.amount.isEmpty)
|
||||
assert(pr.paymentHash == BinaryData("0001020304050607080900010203040506070809000102030405060708090102"))
|
||||
assert(pr.timestamp == 1496314658L)
|
||||
assert(pr.nodeId == PublicKey(BinaryData("03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad")))
|
||||
assert(pr.description == Left("Please consider supporting this project"))
|
||||
assert(pr.fallbackAddress === None)
|
||||
assert(pr.tags.size === 2)
|
||||
assert(PaymentRequest.write(pr.sign(priv)) == ref)
|
||||
}
|
||||
|
||||
test("Please send $3 for a cup of coffee to the same peer, within 1 minute") {
|
||||
val ref = "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp"
|
||||
val pr = PaymentRequest.read(ref)
|
||||
assert(pr.prefix == "lnbc")
|
||||
assert(pr.amount == Some(MilliSatoshi(250000000L)))
|
||||
assert(pr.paymentHash == BinaryData("0001020304050607080900010203040506070809000102030405060708090102"))
|
||||
assert(pr.timestamp == 1496314658L)
|
||||
assert(pr.nodeId == PublicKey(BinaryData("03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad")))
|
||||
assert(pr.description == Left("1 cup coffee"))
|
||||
assert(pr.fallbackAddress === None)
|
||||
assert(pr.tags.size === 3)
|
||||
assert(PaymentRequest.write(pr.sign(priv)) == ref)
|
||||
}
|
||||
|
||||
test("Now send $24 for an entire list of things (hashed)") {
|
||||
val ref = "lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqscc6gd6ql3jrc5yzme8v4ntcewwz5cnw92tz0pc8qcuufvq7khhr8wpald05e92xw006sq94mg8v2ndf4sefvf9sygkshp5zfem29trqq2yxxz7"
|
||||
val pr = PaymentRequest.read(ref)
|
||||
assert(pr.prefix == "lnbc")
|
||||
assert(pr.amount == Some(MilliSatoshi(2000000000L)))
|
||||
assert(pr.paymentHash == BinaryData("0001020304050607080900010203040506070809000102030405060708090102"))
|
||||
assert(pr.timestamp == 1496314658L)
|
||||
assert(pr.nodeId == PublicKey(BinaryData("03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad")))
|
||||
assert(pr.description == Right(Crypto.sha256("One piece of chocolate cake, one icecream cone, one pickle, one slice of swiss cheese, one slice of salami, one lollypop, one piece of cherry pie, one sausage, one cupcake, and one slice of watermelon".getBytes)))
|
||||
assert(pr.fallbackAddress === None)
|
||||
assert(pr.tags.size === 2)
|
||||
assert(PaymentRequest.write(pr.sign(priv)) == ref)
|
||||
}
|
||||
|
||||
test("The same, on testnet, with a fallback address mk2QpYatsKicvFVuTAQLBryyccRXMUaGHP") {
|
||||
val ref = "lntb20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3x9et2e20v6pu37c5d9vax37wxq72un98k6vcx9fz94w0qf237cm2rqv9pmn5lnexfvf5579slr4zq3u8kmczecytdx0xg9rwzngp7e6guwqpqlhssu04sucpnz4axcv2dstmknqq6jsk2l"
|
||||
val pr = PaymentRequest.read(ref)
|
||||
assert(pr.prefix == "lntb")
|
||||
assert(pr.amount == Some(MilliSatoshi(2000000000L)))
|
||||
assert(pr.paymentHash == BinaryData("0001020304050607080900010203040506070809000102030405060708090102"))
|
||||
assert(pr.timestamp == 1496314658L)
|
||||
assert(pr.nodeId == PublicKey(BinaryData("03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad")))
|
||||
assert(pr.description == Right(Crypto.sha256("One piece of chocolate cake, one icecream cone, one pickle, one slice of swiss cheese, one slice of salami, one lollypop, one piece of cherry pie, one sausage, one cupcake, and one slice of watermelon".getBytes)))
|
||||
assert(pr.fallbackAddress === Some("mk2QpYatsKicvFVuTAQLBryyccRXMUaGHP"))
|
||||
assert(pr.tags.size == 3)
|
||||
assert(PaymentRequest.write(pr.sign(priv)) == ref)
|
||||
}
|
||||
|
||||
test("On mainnet, with fallback address 1RustyRX2oai4EYYDpQGWvEL62BBGqN9T with extra routing info to get to node 029e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255") {
|
||||
val ref = "lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85frzjq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqqqqqqq9qqqvncsk57n4v9ehw86wq8fzvjejhv9z3w3q5zh6qkql005x9xl240ch23jk79ujzvr4hsmmafyxghpqe79psktnjl668ntaf4ne7ucs5csqh5mnnk"
|
||||
val pr = PaymentRequest.read(ref)
|
||||
assert(pr.prefix == "lnbc")
|
||||
assert(pr.amount == Some(MilliSatoshi(2000000000L)))
|
||||
assert(pr.paymentHash == BinaryData("0001020304050607080900010203040506070809000102030405060708090102"))
|
||||
assert(pr.timestamp == 1496314658L)
|
||||
assert(pr.nodeId == PublicKey(BinaryData("03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad")))
|
||||
assert(pr.description == Right(Crypto.sha256("One piece of chocolate cake, one icecream cone, one pickle, one slice of swiss cheese, one slice of salami, one lollypop, one piece of cherry pie, one sausage, one cupcake, and one slice of watermelon".getBytes)))
|
||||
assert(pr.fallbackAddress === Some("1RustyRX2oai4EYYDpQGWvEL62BBGqN9T"))
|
||||
assert(pr.routingInfo() === RoutingInfoTag(PublicKey("029e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255"), "0102030405060708", 20, 3) :: Nil)
|
||||
assert(pr.tags.size == 4)
|
||||
assert(PaymentRequest.write(pr.sign(priv)) == ref)
|
||||
}
|
||||
|
||||
|
||||
test("On mainnet, with fallback (p2sh) address 3EktnHQD7RiAE6uzMj2ZifT9YgRrkSgzQX") {
|
||||
val ref = "lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfppj3a24vwu6r8ejrss3axul8rxldph2q7z9kk822r8plup77n9yq5ep2dfpcydrjwzxs0la84v3tfw43t3vqhek7f05m6uf8lmfkjn7zv7enn76sq65d8u9lxav2pl6x3xnc2ww3lqpagnh0u"
|
||||
val pr = PaymentRequest.read(ref)
|
||||
assert(pr.prefix == "lnbc")
|
||||
assert(pr.amount == Some(MilliSatoshi(2000000000L)))
|
||||
assert(pr.paymentHash == BinaryData("0001020304050607080900010203040506070809000102030405060708090102"))
|
||||
assert(pr.timestamp == 1496314658L)
|
||||
assert(pr.nodeId == PublicKey(BinaryData("03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad")))
|
||||
assert(pr.description == Right(Crypto.sha256("One piece of chocolate cake, one icecream cone, one pickle, one slice of swiss cheese, one slice of salami, one lollypop, one piece of cherry pie, one sausage, one cupcake, and one slice of watermelon".getBytes)))
|
||||
assert(pr.fallbackAddress === Some("3EktnHQD7RiAE6uzMj2ZifT9YgRrkSgzQX"))
|
||||
assert(pr.tags.size == 3)
|
||||
assert(PaymentRequest.write(pr.sign(priv)) == ref)
|
||||
}
|
||||
|
||||
test("On mainnet, with fallback (p2wpkh) address bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") {
|
||||
val ref = "lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfppqw508d6qejxtdg4y5r3zarvary0c5xw7kknt6zz5vxa8yh8jrnlkl63dah48yh6eupakk87fjdcnwqfcyt7snnpuz7vp83txauq4c60sys3xyucesxjf46yqnpplj0saq36a554cp9wt865"
|
||||
val pr = PaymentRequest.read(ref)
|
||||
assert(pr.prefix == "lnbc")
|
||||
assert(pr.amount == Some(MilliSatoshi(2000000000L)))
|
||||
assert(pr.paymentHash == BinaryData("0001020304050607080900010203040506070809000102030405060708090102"))
|
||||
assert(pr.timestamp == 1496314658L)
|
||||
assert(pr.nodeId == PublicKey(BinaryData("03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad")))
|
||||
assert(pr.description == Right(Crypto.sha256("One piece of chocolate cake, one icecream cone, one pickle, one slice of swiss cheese, one slice of salami, one lollypop, one piece of cherry pie, one sausage, one cupcake, and one slice of watermelon".getBytes)))
|
||||
assert(pr.fallbackAddress === Some("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"))
|
||||
assert(pr.tags.size == 3)
|
||||
assert(PaymentRequest.write(pr.sign(priv)) == ref)
|
||||
}
|
||||
|
||||
|
||||
test("On mainnet, with fallback (p2wsh) address bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3") {
|
||||
val ref = "lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfp4qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qvnjha2auylmwrltv2pkp2t22uy8ura2xsdwhq5nm7s574xva47djmnj2xeycsu7u5v8929mvuux43j0cqhhf32wfyn2th0sv4t9x55sppz5we8"
|
||||
val pr = PaymentRequest.read(ref)
|
||||
assert(pr.prefix == "lnbc")
|
||||
assert(pr.amount == Some(MilliSatoshi(2000000000L)))
|
||||
assert(pr.paymentHash == BinaryData("0001020304050607080900010203040506070809000102030405060708090102"))
|
||||
assert(pr.timestamp == 1496314658L)
|
||||
assert(pr.nodeId == PublicKey(BinaryData("03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad")))
|
||||
assert(pr.description == Right(Crypto.sha256("One piece of chocolate cake, one icecream cone, one pickle, one slice of swiss cheese, one slice of salami, one lollypop, one piece of cherry pie, one sausage, one cupcake, and one slice of watermelon".getBytes)))
|
||||
assert(pr.fallbackAddress === Some("bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3"))
|
||||
assert(pr.tags.size == 3)
|
||||
assert(PaymentRequest.write(pr.sign(priv)) == ref)
|
||||
}
|
||||
}
|
@ -99,5 +99,10 @@
|
||||
<artifactId>eclair-node_${scala.version.short}</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>3.3.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
@ -7,6 +7,10 @@
|
||||
|
||||
/* ---------- Text Utilities (color, weight) ---------- */
|
||||
|
||||
.text-mono {
|
||||
-fx-font-family: monospace;
|
||||
}
|
||||
|
||||
.text-strong {
|
||||
-fx-font-weight: bold;
|
||||
}
|
||||
@ -44,6 +48,7 @@
|
||||
-fx-fill: rgb(93,199,254);
|
||||
}
|
||||
|
||||
.text-muted,
|
||||
.label.text-muted {
|
||||
-fx-text-fill: rgb(146,149,151);
|
||||
}
|
||||
@ -96,6 +101,16 @@
|
||||
-fx-border-color: transparent;
|
||||
-fx-padding: 0;
|
||||
}
|
||||
.text-area.noteditable .scroll-pane {
|
||||
-fx-background-color: transparent;
|
||||
}
|
||||
.text-area.noteditable .scroll-pane .viewport{
|
||||
-fx-background-color: transparent;
|
||||
}
|
||||
.text-area.noteditable .scroll-pane .content{
|
||||
-fx-background-color: transparent;
|
||||
-fx-padding: 0;
|
||||
}
|
||||
|
||||
/* ---------- Progress Bar ---------- */
|
||||
|
||||
|
Binary file not shown.
After Width: | Height: | Size: 318 B |
@ -132,7 +132,6 @@
|
||||
-fx-background-color: #353535;
|
||||
}
|
||||
|
||||
|
||||
/* ------------- Activity tab -------------- */
|
||||
|
||||
.activities-tab.tab-pane > *.tab-header-area {
|
||||
@ -182,4 +181,30 @@
|
||||
-fx-border-width: 1px;
|
||||
-fx-border-color: #888888;
|
||||
-fx-background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
|
||||
/* -------------- Receive Modal ------------------ */
|
||||
|
||||
.result-box {
|
||||
-fx-background-color: #ffffff;
|
||||
-fx-border-width: 1px 0 0 0;
|
||||
-fx-border-color: rgb(210,210,210);
|
||||
}
|
||||
|
||||
.button.copy-clipboard {
|
||||
-fx-background-color: rgb(240,240,240);
|
||||
-fx-background-image: url("../commons/images/copy_icon.png");
|
||||
-fx-background-repeat: no-repeat;
|
||||
-fx-background-size: 12px;
|
||||
-fx-background-position: 4px center;
|
||||
-fx-border-color: transparent;
|
||||
-fx-padding: 2px 4px 2px 20px;
|
||||
-fx-font-size: 12px;
|
||||
}
|
||||
.button.copy-clipboard:hover {
|
||||
-fx-background-color: rgb(230,232,235);
|
||||
}
|
||||
.button.copy-clipboard:pressed {
|
||||
-fx-background-color: rgb(220,222,225);
|
||||
}
|
@ -1,57 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import javafx.scene.text.Text?>
|
||||
<?import java.net.URL?>
|
||||
<?import javafx.collections.FXCollections?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.image.ImageView?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import java.lang.String?>
|
||||
<GridPane styleClass="grid" prefWidth="550.0" prefHeight="300.0" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints halignment="RIGHT" hgrow="SOMETIMES" maxWidth="210.0" minWidth="10.0" prefWidth="200.0"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" minWidth="10.0" prefWidth="120.0"/>
|
||||
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="120.0"/>
|
||||
</columnConstraints>
|
||||
<rowConstraints>
|
||||
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
|
||||
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
|
||||
<RowConstraints fillHeight="false" minHeight="0.0" prefHeight="1.0" valignment="CENTER" vgrow="SOMETIMES"/>
|
||||
<RowConstraints minHeight="10.0" valignment="TOP" vgrow="ALWAYS"/>
|
||||
</rowConstraints>
|
||||
<?import java.net.URL?>
|
||||
<VBox xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1" prefWidth="590.0">
|
||||
<children>
|
||||
<VBox alignment="CENTER_RIGHT" GridPane.rowIndex="0">
|
||||
<GridPane styleClass="grid">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints halignment="RIGHT" hgrow="SOMETIMES" maxWidth="250.0" minWidth="10.0" prefWidth="250.0"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" minWidth="10.0" prefWidth="120.0"/>
|
||||
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="120.0"/>
|
||||
</columnConstraints>
|
||||
<rowConstraints>
|
||||
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
|
||||
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
|
||||
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
|
||||
</rowConstraints>
|
||||
<children>
|
||||
<Label styleClass="text-strong" text="Amount to receive" />
|
||||
<Label styleClass="label-description" wrapText="true" textAlignment="RIGHT" text="Maximum of ~0.042 BTC" />
|
||||
<VBox alignment="TOP_RIGHT" GridPane.rowIndex="0">
|
||||
<children>
|
||||
<Label styleClass="text-strong" text="Amount to receive" />
|
||||
<Label styleClass="label-description" wrapText="true" textAlignment="RIGHT" text="Maximum of ~0.042 BTC" />
|
||||
</children>
|
||||
</VBox>
|
||||
<TextField fx:id="amount" GridPane.columnIndex="1" GridPane.rowIndex="0"/>
|
||||
<ComboBox fx:id="unit" GridPane.columnIndex="2" GridPane.rowIndex="0" GridPane.halignment="RIGHT">
|
||||
<items>
|
||||
<FXCollections fx:factory="observableArrayList">
|
||||
<String fx:id="milliBTC" fx:value="milliBTC" />
|
||||
<String fx:id="Satoshi" fx:value="Satoshi" />
|
||||
<String fx:id="milliSatoshi" fx:value="milliSatoshi" />
|
||||
</FXCollections>
|
||||
</items>
|
||||
</ComboBox>
|
||||
<Label fx:id="amountError" opacity="0.0" styleClass="text-error, text-error-downward" text="Generic Invalid Amount"
|
||||
mouseTransparent="true" GridPane.rowIndex="0" GridPane.columnIndex="1" GridPane.columnSpan="2"/>
|
||||
|
||||
<VBox alignment="TOP_RIGHT" GridPane.rowIndex="1" GridPane.columnIndex="0">
|
||||
<children>
|
||||
<Label styleClass="text-strong" text="Optional description" />
|
||||
<Label styleClass="label-description" wrapText="true" textAlignment="RIGHT" text="Can be left empty" />
|
||||
</children>
|
||||
</VBox>
|
||||
<TextArea fx:id="description" GridPane.columnIndex="1" GridPane.rowIndex="1" GridPane.columnSpan="2" wrapText="true" prefHeight="50.0" />
|
||||
|
||||
<Button defaultButton="true" mnemonicParsing="false" onAction="#handleGenerate" prefHeight="29.0"
|
||||
prefWidth="95.0" text="Generate" GridPane.columnIndex="1" GridPane.rowIndex="2"/>
|
||||
<Button cancelButton="true" mnemonicParsing="false" onAction="#handleClose" styleClass="cancel" text="Close"
|
||||
GridPane.columnIndex="2" GridPane.halignment="RIGHT" GridPane.rowIndex="2" opacity="0" focusTraversable="false"/>
|
||||
|
||||
</children>
|
||||
</VBox>
|
||||
<TextField fx:id="amount" GridPane.columnIndex="1" GridPane.rowIndex="0"/>
|
||||
<ComboBox fx:id="unit" GridPane.columnIndex="2" GridPane.rowIndex="0" GridPane.halignment="RIGHT">
|
||||
<items>
|
||||
<FXCollections fx:factory="observableArrayList">
|
||||
<String fx:id="milliBTC" fx:value="milliBTC" />
|
||||
<String fx:id="Satoshi" fx:value="Satoshi" />
|
||||
<String fx:id="milliSatoshi" fx:value="milliSatoshi" />
|
||||
</FXCollections>
|
||||
</items>
|
||||
</ComboBox>
|
||||
<Label fx:id="amountError" opacity="0.0" styleClass="text-error, text-error-downward" text="Generic Invalid Amount"
|
||||
mouseTransparent="true" GridPane.rowIndex="0" GridPane.columnIndex="1" GridPane.columnSpan="2"/>
|
||||
|
||||
<Button defaultButton="true" mnemonicParsing="false" onAction="#handleGenerate" prefHeight="29.0"
|
||||
prefWidth="95.0" text="Generate" GridPane.columnIndex="1" GridPane.rowIndex="1"/>
|
||||
<Button cancelButton="true" mnemonicParsing="false" onAction="#handleClose" styleClass="cancel" text="Close"
|
||||
GridPane.columnIndex="2" GridPane.halignment="RIGHT" GridPane.rowIndex="1"/>
|
||||
|
||||
<Separator prefHeight="1.0" GridPane.columnSpan="3" GridPane.rowIndex="2"/>
|
||||
|
||||
<Text strokeType="OUTSIDE" strokeWidth="0.0"
|
||||
text="Send this Payment Request to the person owing you the amount above"
|
||||
textAlignment="RIGHT" wrappingWidth="188.9560546875" GridPane.rowIndex="3"/>
|
||||
<TextArea fx:id="paymentRequest" editable="false" prefHeight="80.0" prefWidth="275.0" styleClass="ta"
|
||||
wrapText="true" GridPane.columnIndex="1" GridPane.columnSpan="2" GridPane.rowIndex="3"/>
|
||||
</GridPane>
|
||||
<GridPane fx:id="resultBox" styleClass="grid, result-box" visible="false">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints halignment="LEFT" hgrow="ALWAYS" maxWidth="250.0" minWidth="10.0" prefWidth="250.0"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" minWidth="200.0" prefWidth="240.0"/>
|
||||
</columnConstraints>
|
||||
<rowConstraints>
|
||||
<RowConstraints minHeight="10.0" vgrow="ALWAYS"/>
|
||||
</rowConstraints>
|
||||
<children>
|
||||
<ImageView fx:id="paymentRequestQRCode" fitWidth="250.0" pickOnBounds="true" preserveRatio="true"
|
||||
GridPane.rowIndex="0" GridPane.columnIndex="0"></ImageView>
|
||||
<VBox spacing="10.0" GridPane.rowIndex="0" GridPane.columnIndex="1">
|
||||
<children>
|
||||
<HBox spacing="10.0" alignment="CENTER_LEFT">
|
||||
<children>
|
||||
<Label text="Invoice:" styleClass="text-strong" />
|
||||
<Button mnemonicParsing="false" onAction="#handleCopyInvoice" styleClass="copy-clipboard"
|
||||
text="Copy to Clipboard" GridPane.columnIndex="1" GridPane.rowIndex="2" />
|
||||
</children>
|
||||
</HBox>
|
||||
<TextArea fx:id="paymentRequestTextArea" prefHeight="200.0" editable="false" styleClass="noteditable, text-sm, text-mono" wrapText="true" />
|
||||
</children>
|
||||
</VBox>
|
||||
</children>
|
||||
</GridPane>
|
||||
</children>
|
||||
<stylesheets>
|
||||
<URL value="@../commons/globals.css"/>
|
||||
<URL value="@../main/main.css"/>
|
||||
</stylesheets>
|
||||
</GridPane>
|
||||
</VBox>
|
@ -69,8 +69,8 @@ class Handlers(setup: Setup) extends Logging {
|
||||
}
|
||||
}
|
||||
|
||||
def receive(amountMsat: MilliSatoshi): Future[String] =
|
||||
(paymentHandler ? ReceivePayment(amountMsat)).mapTo[PaymentRequest].map(PaymentRequest.write(_))
|
||||
def receive(amountMsat: MilliSatoshi, description: String): Future[String] =
|
||||
(paymentHandler ? ReceivePayment(amountMsat, description)).mapTo[PaymentRequest].map(PaymentRequest.write(_))
|
||||
|
||||
def exportToDot(file: File) = (router ? 'dot).mapTo[String].map(
|
||||
dot => printToFile(file)(writer => writer.write(dot)))
|
||||
|
@ -4,16 +4,22 @@ import javafx.application.Platform
|
||||
import javafx.event.ActionEvent
|
||||
import javafx.fxml.FXML
|
||||
import javafx.scene.control.{ComboBox, Label, TextArea, TextField}
|
||||
import javafx.scene.image.{ImageView, WritableImage}
|
||||
import javafx.scene.layout.GridPane
|
||||
import javafx.scene.paint.Color
|
||||
import javafx.stage.Stage
|
||||
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import com.google.zxing.{BarcodeFormat, EncodeHintType}
|
||||
import fr.acinq.bitcoin.MilliSatoshi
|
||||
import fr.acinq.eclair.Setup
|
||||
import fr.acinq.eclair.gui.Handlers
|
||||
import fr.acinq.eclair.gui.utils.GUIValidators
|
||||
import fr.acinq.eclair.gui.utils.{ContextMenuUtils, GUIValidators}
|
||||
import fr.acinq.eclair.payment.PaymentRequest
|
||||
import grizzled.slf4j.Logging
|
||||
|
||||
import scala.util.{Failure, Success}
|
||||
import scala.util.{Failure, Success, Try}
|
||||
|
||||
/**
|
||||
* Created by DPA on 23/09/2016.
|
||||
@ -23,56 +29,102 @@ class ReceivePaymentController(val handlers: Handlers, val stage: Stage, val set
|
||||
@FXML var amount: TextField = _
|
||||
@FXML var amountError: Label = _
|
||||
@FXML var unit: ComboBox[String] = _
|
||||
@FXML var description: TextArea = _
|
||||
|
||||
// this field is generated and readonly
|
||||
@FXML var paymentRequest: TextArea = _
|
||||
@FXML var resultBox: GridPane = _
|
||||
// the content of this field is generated and readonly
|
||||
@FXML var paymentRequestTextArea: TextArea = _
|
||||
@FXML var paymentRequestQRCode: ImageView = _
|
||||
|
||||
@FXML def initialize = unit.setValue(unit.getItems.get(0))
|
||||
@FXML def initialize = {
|
||||
unit.setValue(unit.getItems.get(0))
|
||||
resultBox.managedProperty().bind(resultBox.visibleProperty())
|
||||
stage.sizeToScene()
|
||||
}
|
||||
|
||||
@FXML def handleCopyInvoice(event: ActionEvent) = ContextMenuUtils.copyToClipboard(paymentRequestTextArea.getText)
|
||||
|
||||
@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))
|
||||
&& 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")
|
||||
}
|
||||
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.maxAmountMsat}%,d msat (~${PaymentRequest.maxAmountMsat / 1e11}%.3f BTC)", smartAmount.amount < PaymentRequest.maxAmountMsat)) {
|
||||
import scala.concurrent.ExecutionContext.Implicits.global
|
||||
handlers.receive(smartAmount) onComplete {
|
||||
case Success(s) => Platform.runLater(new Runnable {
|
||||
def run = {
|
||||
paymentRequest.setText(s)
|
||||
paymentRequest.requestFocus
|
||||
paymentRequest.selectAll
|
||||
}})
|
||||
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}")
|
||||
paymentRequest.setText("")
|
||||
amountError.setText("Amount is incorrect")
|
||||
amountError.setOpacity(1)
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def displayPaymentRequest(pr: String, image: Option[WritableImage]) = Platform.runLater(new Runnable {
|
||||
def run = {
|
||||
paymentRequestTextArea.setText(pr)
|
||||
if ("".equals(pr)) {
|
||||
resultBox.setVisible(false)
|
||||
resultBox.setMaxHeight(0)
|
||||
} else {
|
||||
resultBox.setVisible(true)
|
||||
resultBox.setMaxHeight(Double.MaxValue)
|
||||
}
|
||||
image.map(paymentRequestQRCode.setImage(_))
|
||||
stage.sizeToScene()
|
||||
}
|
||||
})
|
||||
|
||||
private def createQRCode(data: String, width: Int = 250, height: Int = 250, margin: Int = -1): WritableImage = {
|
||||
import scala.collection.JavaConversions._
|
||||
val hintMap = collection.mutable.Map[EncodeHintType, Object]()
|
||||
hintMap.put(EncodeHintType.CHARACTER_SET, "UTF-8")
|
||||
hintMap.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.L)
|
||||
hintMap.put(EncodeHintType.MARGIN, margin.toString)
|
||||
val qrWriter = new QRCodeWriter
|
||||
val byteMatrix = qrWriter.encode(data, BarcodeFormat.QR_CODE, width, height, hintMap)
|
||||
val writableImage = new WritableImage(width, height)
|
||||
val pixelWriter = writableImage.getPixelWriter
|
||||
for (i <- 0 to byteMatrix.getWidth - 1) {
|
||||
for (j <- 0 to byteMatrix.getWidth - 1) {
|
||||
if (byteMatrix.get(i, j)) {
|
||||
pixelWriter.setColor(i, j, Color.BLACK)
|
||||
} else {
|
||||
pixelWriter.setColor(i, j, Color.WHITE)
|
||||
}
|
||||
}
|
||||
}
|
||||
writableImage
|
||||
}
|
||||
|
||||
@FXML def handleClose(event: ActionEvent) = stage.close
|
||||
}
|
||||
|
@ -48,41 +48,29 @@ class SendPaymentController(val handlers: Handlers, val stage: Stage, val setup:
|
||||
})
|
||||
paymentRequest.textProperty.addListener(new ChangeListener[String] {
|
||||
def changed(observable: ObservableValue[_ <: String], oldValue: String, newValue: String) = {
|
||||
if (GUIValidators.validate(paymentRequest.getText, paymentRequestError, "Please use a valid payment request", GUIValidators.paymentRequestRegex)) {
|
||||
Try(PaymentRequest.read(paymentRequest.getText)) match {
|
||||
case Success(pr) =>
|
||||
amountField.setText(pr.amount.amount.toString)
|
||||
nodeIdField.setText(pr.nodeId.toString)
|
||||
hashField.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")
|
||||
}
|
||||
} else {
|
||||
amountField.setText("0")
|
||||
nodeIdField.setText("N/A")
|
||||
hashField.setText("N/A")
|
||||
Try(PaymentRequest.read(paymentRequest.getText)) match {
|
||||
case Success(pr) =>
|
||||
pr.amount.foreach(amount => amountField.setText(amount.amount.toString))
|
||||
nodeIdField.setText(pr.nodeId.toString)
|
||||
hashField.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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@FXML def handleSend(event: ActionEvent) = {
|
||||
if (GUIValidators.validate(paymentRequest.getText, paymentRequestError, "Please use a valid payment request", GUIValidators.paymentRequestRegex)) {
|
||||
val Array(nodeId, amount, hash) = paymentRequest.getText.split(":")
|
||||
Try(amount.toLong) match {
|
||||
case Success(amountLong) =>
|
||||
if (GUIValidators.validate(paymentRequestError, "Amount must be greater than 0", amountLong > 0)
|
||||
&& GUIValidators.validate(paymentRequestError, f"Amount must be less than ${PaymentRequest.maxAmountMsat}%,d msat (~${PaymentRequest.maxAmountMsat / 1e11}%.3f BTC)", amountLong < PaymentRequest.maxAmountMsat)) {
|
||||
Try (handlers.send(PublicKey(nodeId), BinaryData(hash), amountLong)) match {
|
||||
case Success(s) => stage.close
|
||||
case Failure(f) => GUIValidators.validate(paymentRequestError, s"Invalid Payment Request: ${f.getMessage}", false)
|
||||
}
|
||||
}
|
||||
case Failure(f) => GUIValidators.validate(paymentRequestError, s"Amount must be numeric", false)
|
||||
}
|
||||
Try(PaymentRequest.read(paymentRequest.getText)) match {
|
||||
case Success(pr) =>
|
||||
Try(handlers.send(pr.nodeId, pr.paymentHash, pr.amount.get.amount)) match {
|
||||
case Success(s) => stage.close
|
||||
case Failure(f) => GUIValidators.validate(paymentRequestError, s"Invalid Payment Request: ${f.getMessage}", false)
|
||||
}
|
||||
case Failure(f) => GUIValidators.validate(paymentRequestError, "cannot parse payment request", false)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -17,10 +17,11 @@ class ReceivePaymentStage(handlers: Handlers, setup: Setup) extends Stage() {
|
||||
initStyle(StageStyle.DECORATED)
|
||||
getIcons().add(new Image("/gui/commons/images/eclair-square.png", false))
|
||||
setTitle("Receive a Payment")
|
||||
setMinWidth(550)
|
||||
setWidth(550)
|
||||
setMinHeight(300)
|
||||
setHeight(300)
|
||||
setMinWidth(590)
|
||||
setWidth(590)
|
||||
setMinHeight(200)
|
||||
setHeight(200)
|
||||
setResizable(false)
|
||||
|
||||
// get fxml/controller
|
||||
val receivePayment = new FXMLLoader(getClass.getResource("/gui/modals/receivePayment.fxml"))
|
||||
|
Loading…
Reference in New Issue
Block a user