1
0
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:
Pierre-Marie Padiou 2017-07-12 20:03:41 +02:00 committed by GitHub
parent 7427cdb27b
commit e7833055bd
19 changed files with 1062 additions and 182 deletions

View File

@ -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) =>

View File

@ -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)
}

View File

@ -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,12 +38,14 @@ 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)) {
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(IncorrectPaymentAmount), commit = true)
}
} else {
sender ! CMD_FAIL_HTLC(htlc.id, Right(UnknownPaymentHash), commit = true)

View File

@ -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

View File

@ -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) {
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")
/**
*
* @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
}
/**
*
* @return the hash of this payment request
*/
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)
}
}
object PaymentRequest {
// https://github.com/lightningnetwork/lightning-rfc/blob/master/02-peer-protocol.md#adding-an-htlc-update_add_htlc
val maxAmountMsat = 4294967296L
val maxAmount = MilliSatoshi(4294967296L)
def write(pr: PaymentRequest): String = {
s"${pr.nodeId.toString}:${pr.amount.amount}:${pr.paymentHash.toString}"
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]
}
/**
* Parse a string and if the string is correctly formatted, returns a PaymentRequest object.
* Otherwise, throws and exception
* Payment Hash Tag
*
* @param pr payment request string, should look like <pre>node:amount:hash</pre>
* @return a PaymentRequest object
* @param hash payment hash
*/
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
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"
}
}
}
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 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)
}
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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>

View File

@ -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

View File

@ -132,7 +132,6 @@
-fx-background-color: #353535;
}
/* ------------- Activity tab -------------- */
.activities-tab.tab-pane > *.tab-header-area {
@ -183,3 +182,29 @@
-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);
}

View File

@ -1,25 +1,26 @@
<?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">
<?import java.net.URL?>
<VBox xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1" prefWidth="590.0">
<children>
<GridPane styleClass="grid">
<columnConstraints>
<ColumnConstraints halignment="RIGHT" hgrow="SOMETIMES" maxWidth="210.0" minWidth="10.0" prefWidth="200.0"/>
<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 fillHeight="false" minHeight="0.0" prefHeight="1.0" valignment="CENTER" vgrow="SOMETIMES"/>
<RowConstraints minHeight="10.0" valignment="TOP" vgrow="ALWAYS"/>
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
</rowConstraints>
<children>
<VBox alignment="CENTER_RIGHT" GridPane.rowIndex="0">
<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" />
@ -38,20 +39,49 @@
<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="1"/>
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="1"/>
GridPane.columnIndex="2" GridPane.halignment="RIGHT" GridPane.rowIndex="2" opacity="0" focusTraversable="false"/>
<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"/>
</children>
</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>

View File

@ -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)))

View File

@ -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,11 +29,20 @@ 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))
@ -50,15 +65,15 @@ class ReceivePaymentController(val handlers: Handlers, val stage: Stage, val set
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)) {
&& 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) onComplete {
case Success(s) => Platform.runLater(new Runnable {
def run = {
paymentRequest.setText(s)
paymentRequest.requestFocus
paymentRequest.selectAll
}})
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)
})
@ -67,12 +82,49 @@ class ReceivePaymentController(val handlers: Handlers, val stage: Stage, val set
} catch {
case e: NumberFormatException =>
logger.debug(s"Could not generate payment request for amount = ${amount.getText}")
paymentRequest.setText("")
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
}

View File

@ -48,10 +48,9 @@ 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)
pr.amount.foreach(amount => amountField.setText(amount.amount.toString))
nodeIdField.setText(pr.nodeId.toString)
hashField.setText(pr.paymentHash.toString)
case Failure(f) =>
@ -60,29 +59,18 @@ class SendPaymentController(val handlers: Handlers, val stage: Stage, val setup:
nodeIdField.setText("N/A")
hashField.setText("N/A")
}
} else {
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 {
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, s"Amount must be numeric", false)
}
case Failure(f) => GUIValidators.validate(paymentRequestError, "cannot parse payment request", false)
}
}

View File

@ -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"))