LnURL Module (#4295)

* LnURL Module

* Add lnurl tests to CI
This commit is contained in:
benthecarman 2022-05-14 17:11:35 -04:00 committed by GitHub
parent 272f31aeaa
commit d60d984a6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 472 additions and 8 deletions

View File

@ -28,4 +28,4 @@ jobs:
~/.bitcoin-s/binaries
key: ${{ runner.os }}-cache
- name: run tests
run: sbt ++2.12.15 coverage chainTest/test chain/coverageReport chain/coverageAggregate chain/coveralls cryptoJVM/test cryptoTestJVM/test cryptoJVM/coverageReport cryptoJVM/coverageAggregate cryptoJVM/coveralls coreTestJVM/test dlcTest/test coreJVM/coverageReport coreJVM/coverageAggregate coreJVM/coveralls secp256k1jni/test zmq/test zmq/coverageReport zmq/coverageAggregate zmq/coveralls appCommonsTest/test appServerTest/test oracleServerTest/test
run: sbt ++2.12.15 coverage chainTest/test chain/coverageReport chain/coverageAggregate chain/coveralls cryptoJVM/test cryptoTestJVM/test cryptoJVM/coverageReport cryptoJVM/coverageAggregate cryptoJVM/coveralls coreTestJVM/test dlcTest/test coreJVM/coverageReport coreJVM/coverageAggregate coreJVM/coveralls secp256k1jni/test zmq/test zmq/coverageReport zmq/coverageAggregate zmq/coveralls appCommonsTest/test appServerTest/test oracleServerTest/test lnurlTest/test

View File

@ -28,4 +28,4 @@ jobs:
~/.bitcoin-s/binaries
key: ${{ runner.os }}-cache
- name: run tests
run: sbt ++2.13.8 coverage chainTest/test chain/coverageReport chain/coverageAggregate chain/coveralls cryptoTestJVM/test cryptoJVM/test cryptoJVM/coverageReport cryptoJVM/coverageAggregate cryptoJVM/coveralls coreTestJVM/test dlcTest/test coreJVM/coverageReport coreJVM/coverageAggregate coreJVM/coveralls secp256k1jni/test zmq/test zmq/coverageReport zmq/coverageAggregate zmq/coveralls appCommonsTest/test appServerTest/test oracleServerTest/test
run: sbt ++2.13.8 coverage chainTest/test chain/coverageReport chain/coverageAggregate chain/coveralls cryptoTestJVM/test cryptoJVM/test cryptoJVM/coverageReport cryptoJVM/coverageAggregate cryptoJVM/coveralls coreTestJVM/test dlcTest/test coreJVM/coverageReport coreJVM/coverageAggregate coreJVM/coveralls secp256k1jni/test zmq/test zmq/coverageReport zmq/coverageAggregate zmq/coveralls appCommonsTest/test appServerTest/test oracleServerTest/test lnurlTest/test

View File

@ -44,7 +44,7 @@ import ujson.{Num, Str, Value}
import scodec.bits.ByteVector
import java.io.File
import java.net.{InetAddress, InetSocketAddress, URI}
import java.net._
import java.nio.file.Path
import java.time._
import java.util.UUID
@ -83,6 +83,12 @@ object JsonReaders {
}
}
implicit object URLReads extends Reads[URL] {
override def reads(json: JsValue): JsResult[URL] =
SerializerUtil.processJsString[URL](str => new URL(str))(json)
}
implicit object ZonedDateTimeReads extends Reads[ZonedDateTime] {
override def reads(json: JsValue): JsResult[ZonedDateTime] =

View File

@ -143,6 +143,16 @@ lazy val clightningRpc = project
.settings(CommonSettings.prodSettings: _*)
.dependsOn(asyncUtilsJVM, bitcoindRpc)
lazy val lnurl = project
.in(file("lnurl"))
.settings(CommonSettings.prodSettings: _*)
.dependsOn(appCommons, asyncUtilsJVM, tor)
lazy val lnurlTest = project
.in(file("lnurl-test"))
.settings(CommonSettings.testSettings: _*)
.dependsOn(lnurl, testkit)
lazy val tor = project
.in(file("tor"))
.settings(CommonSettings.prodSettings: _*)
@ -221,6 +231,8 @@ lazy val `bitcoin-s` = project
serverRoutes,
lndRpc,
lndRpcTest,
lnurl,
lnurlTest,
tor,
torTest,
scripts,
@ -279,6 +291,8 @@ lazy val `bitcoin-s` = project
serverRoutes,
lndRpc,
lndRpcTest,
lnurl,
lnurlTest,
tor,
torTest,
scripts,
@ -763,7 +777,11 @@ lazy val dlcWalletTest = project
name := "bitcoin-s-dlc-wallet-test",
libraryDependencies ++= Deps.dlcWalletTest
)
.dependsOn(coreJVM % testAndCompile, dlcWallet, testkit, testkitCoreJVM, dlcTest)
.dependsOn(coreJVM % testAndCompile,
dlcWallet,
testkit,
testkitCoreJVM,
dlcTest)
lazy val dlcNode = project
.in(file("dlc-node"))

View File

@ -2,6 +2,7 @@ package org.bitcoins.core.currency
import org.bitcoins.core.consensus.Consensus
import org.bitcoins.core.number._
import org.bitcoins.core.protocol.ln.currency.{LnCurrencyUnit, MilliSatoshis}
import org.bitcoins.core.serializers.RawSatoshisSerializer
import org.bitcoins.crypto.{Factory, NetworkElement}
import scodec.bits.ByteVector
@ -85,8 +86,10 @@ sealed abstract class CurrencyUnit
//try removing this and running code, you should see
//failures in the 'walletTest' module
obj match {
case cu: CurrencyUnit => cu.satoshis == satoshis
case _ => false
case cu: CurrencyUnit => cu.satoshis == satoshis
case ln: LnCurrencyUnit => satoshis == ln.toSatoshis
case ms: MilliSatoshis => satoshis == ms.toSatoshis
case _ => false
}
}
}

View File

@ -1,7 +1,7 @@
package org.bitcoins.core.protocol.ln.currency
import org.bitcoins.core._
import org.bitcoins.core.currency.Satoshis
import org.bitcoins.core.currency.{CurrencyUnit, Satoshis}
import org.bitcoins.core.number._
import org.bitcoins.core.protocol.ln._
import org.bitcoins.core.util.Bech32
@ -24,6 +24,21 @@ sealed abstract class LnCurrencyUnit
def ==(ln: LnCurrencyUnit): Boolean =
toPicoBitcoinValue == ln.toPicoBitcoinValue
override def equals(obj: Any): Boolean = {
//needed for cases like
//1BTC == 100,000,000 satoshis should be true
//weirdly enough, this worked in scala version < 2.13.4
//but seems to be broken in 2.13.4 :/
//try removing this and running code, you should see
//failures in the 'lnurl' module
obj match {
case ln: LnCurrencyUnit => toPicoBitcoinValue == ln.toPicoBitcoinValue
case ms: MilliSatoshis => toMSat == ms
case cu: CurrencyUnit => toSatoshis == cu.satoshis
case _ => false
}
}
override def +(ln: LnCurrencyUnit): LnCurrencyUnit = {
PicoBitcoins(toPicoBitcoinValue + ln.toPicoBitcoinValue)
}

View File

@ -51,6 +51,21 @@ sealed abstract class MilliSatoshis
toLnCurrencyUnit != lnCurrencyUnit
}
override def equals(obj: Any): Boolean = {
//needed for cases like
//1BTC == 100,000,000 satoshis should be true
//weirdly enough, this worked in scala version < 2.13.4
//but seems to be broken in 2.13.4 :/
//try removing this and running code, you should see
//failures in the 'lnurl' module
obj match {
case ln: LnCurrencyUnit => toLnCurrencyUnit == ln
case ms: MilliSatoshis => this.toBigInt == ms.toBigInt
case cu: CurrencyUnit => toSatoshis == cu.satoshis
case _ => false
}
}
def >=(ln: LnCurrencyUnit): Boolean = {
toLnCurrencyUnit >= ln
}

View File

@ -240,7 +240,7 @@ sealed abstract class Bech32 {
val length = bech32.length
val maxLength =
// is this a LN invoice or not?
if (bech32.startsWith("ln"))
if (bech32.toLowerCase.startsWith("ln"))
// BOLT 11 is not fully bech32 compatible
// https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#requirements
Integer.MAX_VALUE

View File

@ -0,0 +1,68 @@
package org.bitcoins.lnurl
import org.bitcoins.core.protocol.ln.LnInvoice
import org.bitcoins.core.protocol.ln.currency.MilliSatoshis
import org.bitcoins.lnurl.json.LnURLJsonModels._
import org.bitcoins.testkit.util.BitcoinSAsyncTest
class LnURLClientTest extends BitcoinSAsyncTest {
val client = new LnURLClient(None)
it must "make a pay request" in {
val lnurl = LnURL.fromString(
"LNURL1DP68GURN8GHJ7MRWW4EXCTNXD9SHG6NPVCHXXMMD9AKXUATJDSKHQCTE8AEK2UMND9HKU0TPXUCRJVEHXV6RYVEEX5CNSCESXYURSEP5VE3RWCNP89JRJD33XUEXGCFNX5URZCT9XYMRQDRXVVCKZVRPV43KYV3E8YMKZE3H89SNWEVZTNK")
client.makeRequest(lnurl).map {
case pay: LnURLPayResponse =>
assert(pay.maxSendable >= MilliSatoshis.zero)
assert(pay.minSendable >= MilliSatoshis.zero)
case _: LnURLWithdrawResponse =>
fail("Incorrect response parsed")
}
}
it must "make a pay request and get the invoice" in {
val lnurl = LnURL.fromString(
"LNURL1DP68GURN8GHJ7MRWW4EXCTNXD9SHG6NPVCHXXMMD9AKXUATJDSKHQCTE8AEK2UMND9HKU0TPXUCRJVEHXV6RYVEEX5CNSCESXYURSEP5VE3RWCNP89JRJD33XUEXGCFNX5URZCT9XYMRQDRXVVCKZVRPV43KYV3E8YMKZE3H89SNWEVZTNK")
client.makeRequest(lnurl).flatMap {
case pay: LnURLPayResponse =>
val amt = pay.minSendable.toLnCurrencyUnit
client.getInvoice(pay, amt).map { inv =>
assert(inv.amount.contains(amt))
}
case _: LnURLWithdrawResponse =>
fail("Incorrect response parsed")
}
}
it must "make a withdrawal request" in {
val lnurl = LnURL.fromString(
"LNURL1DP68GURN8GHJ7MRWW4EXCTNXD9SHG6NPVCHXXMMD9AKXUATJDSKHW6T5DPJ8YCTH8AEK2UMND9HKU0FJVSCNZDPHVYENVDTPVYCRSVMPXVMRSCEEXGERQVPSXV6X2C3KX9JXZVMZ8PNXZDR9VY6N2DRZVG6RWEPCVYMRZDMRV9SK2D3KV43XVCF58DT")
client.makeRequest(lnurl).map {
case _: LnURLPayResponse =>
fail("Incorrect response parsed")
case w: LnURLWithdrawResponse =>
assert(w.defaultDescription.nonEmpty)
assert(w.k1.nonEmpty)
assert(w.maxWithdrawable >= MilliSatoshis.zero)
assert(w.minWithdrawable >= MilliSatoshis.zero)
}
}
it must "make a withdrawal request and do withdrawal" in {
val lnurl = LnURL.fromString(
"LNURL1DP68GURN8GHJ7MRWW4EXCTNXD9SHG6NPVCHXXMMD9AKXUATJDSKHW6T5DPJ8YCTH8AEK2UMND9HKU0FJVSCNZDPHVYENVDTPVYCRSVMPXVMRSCEEXGERQVPSXV6X2C3KX9JXZVMZ8PNXZDR9VY6N2DRZVG6RWEPCVYMRZDMRV9SK2D3KV43XVCF58DT")
val inv = LnInvoice.fromString(
"lnbc1302470n1p3x3ssapp5axqf6dsusf98895vdhw97rn0szk4z6cxa5hfw3s2q5ksn3575qssdzz2pskjepqw3hjqnmsv4h9xct5wvszsnmjv3jhygzfgsazqem9dejhyctvtan82mny9ycqzpgxqzuysp5q97feeev2tnjsc0qn9kezqlgs8eekwfkxsc28uwxp9elnzkj2n0s9qyyssq02hkrz7dr0adx09t6w2tr9k8nczvq094r7qx297tsdupgeg5t3m8hvmkl7mqhtvx94he3swlg2qzhqk2j39wehcmv9awc06gex82e8qq0u0pm6")
client.makeRequest(lnurl).flatMap {
case _: LnURLPayResponse =>
fail("Incorrect response parsed")
case w: LnURLWithdrawResponse =>
client.doWithdrawal(w, inv).map(bool => assert(bool))
}
}
}

View File

@ -0,0 +1,31 @@
package org.bitcoins.lnurl
import org.bitcoins.testkitcore.util.BitcoinSUnitTest
import scala.util.{Failure, Success}
class LnURLTest extends BitcoinSUnitTest {
it must "correctly encode" in {
val url =
"https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df"
val expected =
"LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS"
assert(LnURL.fromURL(url).toString.toUpperCase == expected)
}
it must "correctly decode" in {
val str =
"LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS"
val expected =
"https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df"
LnURL.decode(str) match {
case Failure(exception) => fail(exception)
case Success(value) => assert(value == expected)
}
}
}

View File

@ -0,0 +1,59 @@
package org.bitcoins.lnurl
import org.bitcoins.core.number._
import org.bitcoins.core.util._
import org.bitcoins.crypto.StringFactory
import scodec.bits.ByteVector
import java.net.URL
import scala.util.{Failure, Success, Try}
class LnURL private (private val str: String) {
val url: URL = LnURL.decode(str) match {
case Failure(_) =>
throw new IllegalArgumentException("Invalid LnURL encoding")
case Success(value) => new URL(value)
}
override def toString: String = str.toUpperCase
}
object LnURL extends StringFactory[LnURL] {
final val lnurlHRP = "lnurl"
def decode(l: LnURL): Try[String] = decode(l.url)
def decode(url: URL): Try[String] = decode(url.toString)
def decode(url: String): Try[String] = {
Bech32.splitToHrpAndData(url, Bech32Encoding.Bech32).map {
case (hrp, data) =>
require(hrp.equalsIgnoreCase(lnurlHRP),
s"LNURL must start with $lnurlHRP")
val converted = NumberUtil.convertUInt5sToUInt8(data)
val bytes = UInt8.toBytes(converted)
new String(bytes.toArray, "UTF-8")
}
}
override def fromStringT(string: String): Try[LnURL] = {
LnURL.decode(string).map(fromURL)
}
override def fromString(string: String): LnURL = {
fromStringT(string).get
}
def fromURL(uri: String): LnURL = {
val bytes = ByteVector(uri.getBytes)
val data = NumberUtil.convertUInt8sToUInt5s(UInt8.toUInt8s(bytes))
val dataWithHRP = Bech32.hrpExpand(lnurlHRP) ++ data
val checksum = Bech32.createChecksum(dataWithHRP, Bech32Encoding.Bech32)
val all: Vector[UInt5] = data ++ checksum
val encoding = Bech32.encode5bitToString(all)
new LnURL(lnurlHRP + Bech32.separator + encoding)
}
}

View File

@ -0,0 +1,101 @@
package org.bitcoins.lnurl
import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.http.scaladsl.client.RequestBuilding.Get
import akka.http.scaladsl.model.HttpRequest
import akka.util.ByteString
import grizzled.slf4j.Logging
import org.bitcoins.core.currency._
import org.bitcoins.core.protocol.ln.LnInvoice
import org.bitcoins.core.protocol.ln.currency._
import org.bitcoins.lnurl.json._
import org.bitcoins.lnurl.json.LnURLJsonModels._
import org.bitcoins.tor._
import play.api.libs.json._
import java.net.{URI, URL}
import scala.concurrent._
class LnURLClient(proxyParams: Option[Socks5ProxyParams])(implicit
system: ActorSystem)
extends Logging {
implicit protected val ec: ExecutionContext = system.dispatcher
private val http = Http(system)
private def sendRequest(request: HttpRequest): Future[String] = {
val httpConnectionPoolSettings =
Socks5ClientTransport.createConnectionPoolSettings(
new URI(request.uri.toString),
proxyParams)
http
.singleRequest(request, settings = httpConnectionPoolSettings)
.flatMap(response =>
response.entity.dataBytes
.runFold(ByteString.empty)(_ ++ _))
.map(payload => payload.decodeString(ByteString.UTF_8))
}
private def sendRequestAndParse[T <: LnURLJsonModel](request: HttpRequest)(
implicit reads: Reads[T]): Future[T] = {
sendRequest(request)
.map { str =>
val json = Json.parse(str)
json.validate[T] match {
case JsSuccess(value, _) => value
case JsError(errors) =>
json.validate[LnURLStatus] match {
case JsSuccess(value, _) =>
throw new RuntimeException(
value.reason.getOrElse("Error parsing response"))
case JsError(_) =>
throw new RuntimeException(
s"Error parsing json $str, got ${errors.mkString("\n")}")
}
}
}
}
def makeRequest(lnURL: LnURL): Future[LnURLResponse] = {
makeRequest(lnURL.url)
}
def makeRequest(url: URL): Future[LnURLResponse] = {
makeRequest(url.toString)
}
def makeRequest(str: String): Future[LnURLResponse] = {
sendRequestAndParse[LnURLResponse](Get(str))
}
def getInvoice(
pay: LnURLPayResponse,
amount: LnCurrencyUnit): Future[LnInvoice] = {
getInvoice(pay, amount.toSatoshis)
}
def getInvoice(
pay: LnURLPayResponse,
amount: MilliSatoshis): Future[LnInvoice] = {
getInvoice(pay, amount.toSatoshis)
}
def getInvoice(
pay: LnURLPayResponse,
amount: CurrencyUnit): Future[LnInvoice] = {
val msats = MilliSatoshis(amount)
val symbol = if (pay.callback.toString.contains("?")) "&" else "?"
val url = s"${pay.callback}${symbol}amount=${msats.toLong}"
sendRequestAndParse[LnURLPayInvoice](Get(url)).map(_.pr)
}
def doWithdrawal(
withdraw: LnURLWithdrawResponse,
invoice: LnInvoice): Future[Boolean] = {
val symbol = if (withdraw.callback.toString.contains("?")) "&" else "?"
val url = s"${withdraw.callback}${symbol}k1=${withdraw.k1}&pr=$invoice"
sendRequestAndParse[LnURLStatus](Get(url)).map(_.status.toUpperCase == "OK")
}
}

View File

@ -0,0 +1,91 @@
package org.bitcoins.lnurl.json
import org.bitcoins.core.protocol.ln.currency.MilliSatoshis
import org.bitcoins.lnurl.json.LnURLTag._
import play.api.libs.json._
import org.bitcoins.commons.serializers.JsonReaders._
import org.bitcoins.core.protocol.ln.LnInvoice
import java.net._
sealed abstract class LnURLJsonModel
sealed abstract class LnURLResponse extends LnURLJsonModel {
def tag: LnURLTag
def callback: URL
}
case class LnURLStatus(status: String, reason: Option[String])
extends LnURLJsonModel
object LnURLJsonModels {
implicit val LnURLStatusReads: Reads[LnURLStatus] = Json.reads[LnURLStatus]
case class LnURLSuccessAction(
tag: SuccessActionTag,
message: Option[String],
description: Option[String],
url: Option[URL],
ciphertext: Option[String],
iv: Option[String])
implicit val LnURLSuccessActionReads: Reads[LnURLSuccessAction] =
Json.reads[LnURLSuccessAction]
case class LnURLPayResponse(
callback: URL,
maxSendable: MilliSatoshis,
minSendable: MilliSatoshis,
private val metadata: String)
extends LnURLResponse {
override val tag: LnURLTag = PayRequest
lazy val metadataJs: JsValue = Json.parse(metadata)
}
implicit val LnURLPayResponseReads: Reads[LnURLPayResponse] =
Json.reads[LnURLPayResponse]
case class LnURLPayInvoice(
pr: LnInvoice,
successAction: Option[LnURLSuccessAction])
extends LnURLJsonModel
implicit val LnURLPayInvoiceReads: Reads[LnURLPayInvoice] =
Json.reads[LnURLPayInvoice]
case class LnURLWithdrawResponse(
callback: URL,
k1: String,
defaultDescription: String,
minWithdrawable: MilliSatoshis,
maxWithdrawable: MilliSatoshis
) extends LnURLResponse {
override val tag: LnURLTag = WithdrawRequest
}
implicit val LnURLWithdrawResponseReads: Reads[LnURLWithdrawResponse] =
Json.reads[LnURLWithdrawResponse]
implicit val LnURLResponseReads: Reads[LnURLResponse] = {
case other @ (JsNull | _: JsBoolean | JsNumber(_) | JsString(_) | JsArray(
_)) =>
throw new IllegalArgumentException(s"Expected JsObject, got $other")
case obj: JsObject =>
obj.value.get("tag") match {
case None =>
throw new RuntimeException(s"Error parsing json, no tag, got $obj")
case Some(tagJs) =>
tagJs.validate[LnURLTag] match {
case JsError(errors) =>
throw new IllegalArgumentException(
s"Invalid json, got $obj, errors ${errors.mkString("\n")}")
case JsSuccess(tag, _) =>
tag match {
case PayRequest => obj.validate[LnURLPayResponse]
case WithdrawRequest => obj.validate[LnURLWithdrawResponse]
}
}
}
}
}

View File

@ -0,0 +1,57 @@
package org.bitcoins.lnurl.json
import org.bitcoins.commons.serializers.SerializerUtil
import org.bitcoins.crypto.StringFactory
import play.api.libs.json._
sealed abstract class LnURLTag(override val toString: String)
object LnURLTag extends StringFactory[LnURLTag] {
case object WithdrawRequest extends LnURLTag("withdrawRequest")
case object PayRequest extends LnURLTag("payRequest")
val all: Vector[LnURLTag] = Vector(WithdrawRequest, PayRequest)
override def fromStringOpt(string: String): Option[LnURLTag] = {
all.find(_.toString == string)
}
override def fromString(string: String): LnURLTag = {
fromStringOpt(string).getOrElse(
sys.error(s"Could not find a LnURLTag for string $string"))
}
implicit val LnURLTagTagReads: Reads[LnURLTag] = (json: JsValue) =>
SerializerUtil.processJsStringOpt[LnURLTag](fromStringOpt)(json)
implicit val LnURLTagTagWrites: Writes[LnURLTag] = (tag: LnURLTag) =>
JsString(tag.toString)
}
sealed abstract class SuccessActionTag(override val toString: String)
object SuccessActionTag extends StringFactory[SuccessActionTag] {
case object Message extends SuccessActionTag("message")
case object URL extends SuccessActionTag("url")
case object AES extends SuccessActionTag("aes")
val all: Vector[SuccessActionTag] = Vector(Message, URL, AES)
override def fromStringOpt(string: String): Option[SuccessActionTag] = {
all.find(_.toString == string)
}
override def fromString(string: String): SuccessActionTag = {
fromStringOpt(string).getOrElse(
sys.error(s"Could not find a SuccessActionTag for string $string"))
}
implicit val SuccessActionTagReads: Reads[SuccessActionTag] =
(json: JsValue) =>
SerializerUtil.processJsStringOpt[SuccessActionTag](fromStringOpt)(json)
implicit val SuccessActionTagWrites: Writes[SuccessActionTag] =
(tag: SuccessActionTag) => JsString(tag.toString)
}