diff --git a/.github/workflows/Linux_2.12_App_Chain_Core_Tests.yml b/.github/workflows/Linux_2.12_App_Chain_Core_Tests.yml index 02361033fa..6414803f5b 100644 --- a/.github/workflows/Linux_2.12_App_Chain_Core_Tests.yml +++ b/.github/workflows/Linux_2.12_App_Chain_Core_Tests.yml @@ -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 diff --git a/.github/workflows/Linux_2.13_App_Chain_Core_Tests.yml b/.github/workflows/Linux_2.13_App_Chain_Core_Tests.yml index 86d1e8969f..9062e4bee2 100644 --- a/.github/workflows/Linux_2.13_App_Chain_Core_Tests.yml +++ b/.github/workflows/Linux_2.13_App_Chain_Core_Tests.yml @@ -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 diff --git a/app-commons/src/main/scala/org/bitcoins/commons/serializers/JsonReaders.scala b/app-commons/src/main/scala/org/bitcoins/commons/serializers/JsonReaders.scala index 9409db6412..89ffe6d2ec 100644 --- a/app-commons/src/main/scala/org/bitcoins/commons/serializers/JsonReaders.scala +++ b/app-commons/src/main/scala/org/bitcoins/commons/serializers/JsonReaders.scala @@ -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] = diff --git a/build.sbt b/build.sbt index cfe0d53ebb..6aa760f82f 100644 --- a/build.sbt +++ b/build.sbt @@ -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")) diff --git a/core/src/main/scala/org/bitcoins/core/currency/CurrencyUnits.scala b/core/src/main/scala/org/bitcoins/core/currency/CurrencyUnits.scala index 540296a568..e714a324f9 100644 --- a/core/src/main/scala/org/bitcoins/core/currency/CurrencyUnits.scala +++ b/core/src/main/scala/org/bitcoins/core/currency/CurrencyUnits.scala @@ -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 } } } diff --git a/core/src/main/scala/org/bitcoins/core/protocol/ln/currency/LnCurrencyUnit.scala b/core/src/main/scala/org/bitcoins/core/protocol/ln/currency/LnCurrencyUnit.scala index 7883f0a99d..d783578043 100644 --- a/core/src/main/scala/org/bitcoins/core/protocol/ln/currency/LnCurrencyUnit.scala +++ b/core/src/main/scala/org/bitcoins/core/protocol/ln/currency/LnCurrencyUnit.scala @@ -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) } diff --git a/core/src/main/scala/org/bitcoins/core/protocol/ln/currency/MilliSatoshis.scala b/core/src/main/scala/org/bitcoins/core/protocol/ln/currency/MilliSatoshis.scala index f41735a30b..82a36d5e26 100644 --- a/core/src/main/scala/org/bitcoins/core/protocol/ln/currency/MilliSatoshis.scala +++ b/core/src/main/scala/org/bitcoins/core/protocol/ln/currency/MilliSatoshis.scala @@ -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 } diff --git a/core/src/main/scala/org/bitcoins/core/util/Bech32.scala b/core/src/main/scala/org/bitcoins/core/util/Bech32.scala index ce2b9f25cd..8b1bd4ee54 100644 --- a/core/src/main/scala/org/bitcoins/core/util/Bech32.scala +++ b/core/src/main/scala/org/bitcoins/core/util/Bech32.scala @@ -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 diff --git a/lnurl-test/src/test/scala/org/bitcoins/lnurl/LnURLClientTest.scala b/lnurl-test/src/test/scala/org/bitcoins/lnurl/LnURLClientTest.scala new file mode 100644 index 0000000000..cee99a826c --- /dev/null +++ b/lnurl-test/src/test/scala/org/bitcoins/lnurl/LnURLClientTest.scala @@ -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)) + } + } +} diff --git a/lnurl-test/src/test/scala/org/bitcoins/lnurl/LnURLTest.scala b/lnurl-test/src/test/scala/org/bitcoins/lnurl/LnURLTest.scala new file mode 100644 index 0000000000..c08b8445f1 --- /dev/null +++ b/lnurl-test/src/test/scala/org/bitcoins/lnurl/LnURLTest.scala @@ -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) + } + } +} diff --git a/lnurl/src/main/scala/org/bitcoins/lnurl/LnURL.scala b/lnurl/src/main/scala/org/bitcoins/lnurl/LnURL.scala new file mode 100644 index 0000000000..24d25baf7f --- /dev/null +++ b/lnurl/src/main/scala/org/bitcoins/lnurl/LnURL.scala @@ -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) + } + +} diff --git a/lnurl/src/main/scala/org/bitcoins/lnurl/LnURLClient.scala b/lnurl/src/main/scala/org/bitcoins/lnurl/LnURLClient.scala new file mode 100644 index 0000000000..985eda10f4 --- /dev/null +++ b/lnurl/src/main/scala/org/bitcoins/lnurl/LnURLClient.scala @@ -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") + } +} diff --git a/lnurl/src/main/scala/org/bitcoins/lnurl/json/LnURLJsonModels.scala b/lnurl/src/main/scala/org/bitcoins/lnurl/json/LnURLJsonModels.scala new file mode 100644 index 0000000000..7c890b4354 --- /dev/null +++ b/lnurl/src/main/scala/org/bitcoins/lnurl/json/LnURLJsonModels.scala @@ -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] + } + } + } + } +} diff --git a/lnurl/src/main/scala/org/bitcoins/lnurl/json/LnURLTag.scala b/lnurl/src/main/scala/org/bitcoins/lnurl/json/LnURLTag.scala new file mode 100644 index 0000000000..91d9018ac6 --- /dev/null +++ b/lnurl/src/main/scala/org/bitcoins/lnurl/json/LnURLTag.scala @@ -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) +}