diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt index be1e28c..c3f1210 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt @@ -5,6 +5,7 @@ import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Script import fr.acinq.bitcoin.utils.Either +import fr.acinq.bitcoin.utils.Try import fr.acinq.bitcoin.utils.toEither import fr.acinq.lightning.BuildVersions import fr.acinq.lightning.Lightning.randomBytes32 @@ -12,10 +13,15 @@ import fr.acinq.lightning.NodeParams import fr.acinq.lightning.bin.db.SqlitePaymentsDb import fr.acinq.lightning.bin.db.WalletPaymentId import fr.acinq.lightning.bin.json.ApiType.* -import fr.acinq.lightning.bin.json.ApiType.IncomingPayment -import fr.acinq.lightning.bin.json.ApiType.OutgoingPayment +import fr.acinq.lightning.bin.payments.AddressResolver import fr.acinq.lightning.bin.payments.Parser import fr.acinq.lightning.bin.payments.PayDnsAddress +import fr.acinq.lightning.bin.payments.lnurl.LnurlHandler +import fr.acinq.lightning.bin.payments.lnurl.helpers.LnurlParser +import fr.acinq.lightning.bin.payments.lnurl.models.Lnurl +import fr.acinq.lightning.bin.payments.lnurl.models.LnurlAuth +import fr.acinq.lightning.bin.payments.lnurl.models.LnurlPay +import fr.acinq.lightning.bin.payments.lnurl.models.LnurlWithdraw import fr.acinq.lightning.blockchain.fee.FeeratePerByte import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelCommand @@ -23,8 +29,10 @@ import fr.acinq.lightning.channel.states.ChannelStateWithCommitments import fr.acinq.lightning.channel.states.Closed import fr.acinq.lightning.channel.states.Closing import fr.acinq.lightning.channel.states.ClosingFeerates +import fr.acinq.lightning.crypto.LocalKeyManager import fr.acinq.lightning.io.Peer import fr.acinq.lightning.io.WrappedChannelCommand +import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.OfferTypes @@ -51,12 +59,22 @@ import kotlinx.serialization.json.Json import okio.ByteString.Companion.encodeUtf8 import kotlin.time.Duration.Companion.seconds -class Api(private val nodeParams: NodeParams, private val peer: Peer, private val eventsFlow: SharedFlow, private val password: String, private val webhookUrl: Url?, private val webhookSecret: String) { +class Api( + private val nodeParams: NodeParams, + private val peer: Peer, + private val eventsFlow: SharedFlow, + private val password: String, + private val webhookUrl: Url?, + private val webhookSecret: String, + private val loggerFactory: LoggerFactory, +) { @OptIn(ExperimentalSerializationApi::class) fun Application.module() { val payDnsAddress = PayDnsAddress() + val lnurlHandler = LnurlHandler(loggerFactory, nodeParams.keyManager as LocalKeyManager) + val addressResolver = AddressResolver(payDnsAddress, lnurlHandler) val json = Json { prettyPrint = true @@ -212,20 +230,31 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va } post("paylnaddress") { val formParameters = call.receiveParameters() - val overrideAmount = formParameters["amountSat"]?.let { it.toLongOrNull() ?: invalidType("amountSat", "integer") }?.sat?.toMilliSatoshi() + val amount = formParameters.getLong("amountSat").sat.toMilliSatoshi() val (username, domain) = formParameters.getEmailLikeAddress("address") - val offer = payDnsAddress.resolveBip353Offer(username, domain) - when (offer) { - null -> call.respond("no valid offer found for that address") - else -> { - val amount = (overrideAmount ?: offer.amount) ?: missing("amountSat") - val note = formParameters["message"] - when (val event = peer.payOffer(amount, offer, payerKey = nodeParams.defaultOffer(peer.walletParams.trampolineNode.id).second, payerNote = note, fetchInvoiceTimeout = 30.seconds)) { - is fr.acinq.lightning.io.PaymentSent -> call.respond(PaymentSent(event)) - is fr.acinq.lightning.io.PaymentNotSent -> call.respond(PaymentFailed(event)) - is fr.acinq.lightning.io.OfferNotPaid -> call.respond(PaymentFailed(event)) + val note = formParameters["message"] + when (val res = addressResolver.resolveAddress(username, domain, amount, note)) { + is Try.Success -> when (val either = res.result) { + is Either.Left -> { + // LNURL + val lnurlInvoice = either.value + when (val event = peer.payInvoice(amount, lnurlInvoice.invoice)) { + is fr.acinq.lightning.io.PaymentSent -> call.respond(PaymentSent(event)) + is fr.acinq.lightning.io.PaymentNotSent -> call.respond(PaymentFailed(event)) + is fr.acinq.lightning.io.OfferNotPaid -> error("unreachable code") + } + } + is Either.Right -> { + // OFFER + val offer = either.value + when (val event = peer.payOffer(amount, offer, payerKey = nodeParams.defaultOffer(peer.walletParams.trampolineNode.id).second, payerNote = note, fetchInvoiceTimeout = 30.seconds)) { + is fr.acinq.lightning.io.PaymentSent -> call.respond(PaymentSent(event)) + is fr.acinq.lightning.io.PaymentNotSent -> call.respond(PaymentFailed(event)) + is fr.acinq.lightning.io.OfferNotPaid -> call.respond(PaymentFailed(event)) + } } } + is Try.Failure -> error("cannot resolve address: ${res.error.message}") } } post("decodeinvoice") { @@ -238,6 +267,71 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va val offer = formParameters.getOffer("offer") call.respond(offer) } + post("lnurlpay") { + val formParameters = call.receiveParameters() + val overrideAmount = formParameters["amountSat"]?.let { it.toLongOrNull() ?: invalidType("amountSat", "integer") }?.sat?.toMilliSatoshi() + val comment = formParameters["message"] + val request = formParameters.getLnurl("lnurl") + // early abort to avoid executing an invalid url + when (request) { + is LnurlAuth -> badRequest("this is an authentication lnurl") + is Lnurl.Request -> if (request.tag == Lnurl.Tag.Withdraw) badRequest("this is a withdraw lnurl") + else -> Unit + } + try { + val lnurl = lnurlHandler.executeLnurl(request.initialUrl) + when (lnurl) { + is LnurlWithdraw -> badRequest("this is a withdraw lnurl") + is LnurlPay.PaymentParameters -> { + val amount = (overrideAmount ?: lnurl.minSendable) + val invoice = lnurlHandler.getLnurlPayInvoice(lnurl, amount, comment) + when (val event = peer.payInvoice(amount, invoice.invoice)) { + is fr.acinq.lightning.io.PaymentSent -> call.respond(PaymentSent(event)) + is fr.acinq.lightning.io.PaymentNotSent -> call.respond(PaymentFailed(event)) + is fr.acinq.lightning.io.OfferNotPaid -> error("unreachable code") + } + } + else -> badRequest("invalid [${lnurl::class}] lnurl=${lnurl.initialUrl}") + } + } catch (e: Exception) { + badRequest(e.message ?: e::class.toString()) + } + } + post("lnurlwithdraw") { + val formParameters = call.receiveParameters() + val request = formParameters.getLnurl("lnurl") + // early abort to avoid executing an invalid url + when (request) { + is LnurlAuth -> badRequest("this is an authentication lnurl") + is Lnurl.Request -> if (request.tag == Lnurl.Tag.Pay) badRequest("this is a payment lnurl") + else -> Unit + } + try { + val lnurl = lnurlHandler.executeLnurl(request.initialUrl) + when (lnurl) { + is LnurlPay -> badRequest("this is a payment lnurl") + is LnurlWithdraw -> { + val invoice = peer.createInvoice(randomBytes32(), lnurl.maxWithdrawable, Either.Left(lnurl.defaultDescription)) + lnurlHandler.sendWithdrawInvoice(lnurl, invoice) + call.respond(LnurlWithdrawResponse(lnurl, invoice)) + } + else -> badRequest("invalid [${lnurl::class}] lnurl=${lnurl.initialUrl}") + } + } catch (e: Exception) { + badRequest(e.message ?: e::class.toString()) + } + } + post("lnurlauth") { + val formParameters = call.receiveParameters() + val request = formParameters.getLnurl("lnurl") + if (request !is LnurlAuth) badRequest("this is a payment or withdraw lnurl") + try { + lnurlHandler.signAndSendAuthRequest(request) + call.respond("authentication success") + } catch (e: Exception) { + badRequest("could not authenticate: ${e.message ?: e::class.toString()}") + } + } post("sendtoaddress") { val res = kotlin.runCatching { val formParameters = call.receiveParameters() @@ -331,4 +425,7 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va private fun Parameters.getEmailLikeAddress(argName: String): Pair = this[argName]?.let { Parser.parseEmailLikeAddress(it) } ?: invalidType(argName, "username@domain") + private fun Parameters.getLnurl(argName: String): Lnurl = this[argName]?.let { LnurlParser.extractLnurl(it) } ?: missing(argName) + + private fun Parameters.getLnurlAuth(argName: String): LnurlAuth = this[argName]?.let { LnurlParser.extractLnurl(it) as LnurlAuth } ?: missing(argName) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt index 5353dd0..526de5a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt @@ -364,7 +364,7 @@ class Phoenixd : CliktCommand() { reuseAddress = true }, module = { - Api(nodeParams, peer, eventsFlow, httpPassword, webHookUrl, webHookSecret).run { module() } + Api(nodeParams, peer, eventsFlow, httpPassword, webHookUrl, webHookSecret, loggerFactory).run { module() } } ) val serverJob = scope.launch { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt index 6dde917..cf74621 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt @@ -20,15 +20,20 @@ import fr.acinq.bitcoin.Satoshi import fr.acinq.bitcoin.TxId import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.bin.db.PaymentMetadata +import fr.acinq.lightning.bin.payments.lnurl.models.Lnurl +import fr.acinq.lightning.bin.payments.lnurl.models.LnurlWithdraw import fr.acinq.lightning.channel.states.ChannelState import fr.acinq.lightning.channel.states.ChannelStateWithCommitments import fr.acinq.lightning.db.* import fr.acinq.lightning.json.JsonSerializers +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.utils.UUID +import io.ktor.http.* import kotlinx.datetime.Clock import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers +import kotlin.math.ln sealed class ApiType { @@ -133,4 +138,26 @@ sealed class ApiType { createdAt = payment.createdAt, ) } + + @Serializable + @SerialName("lnurl_request") + data class LnurlRequest(val url: String, val tag: String?) { + constructor(lnurl: Lnurl) : this( + url = lnurl.initialUrl.toString(), + tag = if (lnurl is Lnurl.Request) lnurl.tag?.label else null, + ) + } + + @Serializable + @SerialName("lnurl_withdraw") + data class LnurlWithdrawResponse(val url: String, val minWithdrawable: MilliSatoshi, val maxWithdrawable: MilliSatoshi, val description: String, val k1: String, val invoice: String) { + constructor(lnurl: LnurlWithdraw, invoice: Bolt11Invoice) : this( + url = lnurl.initialUrl.toString(), + minWithdrawable = lnurl.minWithdrawable, + maxWithdrawable = lnurl.maxWithdrawable, + description = lnurl.defaultDescription, + k1 = lnurl.k1, + invoice = invoice.write() + ) + } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/AddressResolver.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/AddressResolver.kt new file mode 100644 index 0000000..7d84bc1 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/AddressResolver.kt @@ -0,0 +1,37 @@ +package fr.acinq.lightning.bin.payments + +import fr.acinq.bitcoin.utils.Either +import fr.acinq.bitcoin.utils.Try +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.bin.payments.lnurl.LnurlHandler +import fr.acinq.lightning.bin.payments.lnurl.models.LnurlPay +import fr.acinq.lightning.wire.OfferTypes +import io.ktor.http.* + +class AddressResolver(val dnsAddress: PayDnsAddress, val lnurlHandler: LnurlHandler) { + + suspend fun resolveLnUrl(username: String, domain: String, amount: MilliSatoshi, note: String?): Try { + val url = Url("https://$domain/.well-known/lnurlp/$username") + return try { + val lnurl = lnurlHandler.executeLnurl(url) + val paymentParameters = lnurl as LnurlPay.PaymentParameters + if (amount < paymentParameters.minSendable) throw IllegalArgumentException("amount too small (min=${paymentParameters.minSendable})") + if (amount > paymentParameters.maxSendable) throw IllegalArgumentException("amount too big (max=${paymentParameters.maxSendable})") + val invoice = lnurlHandler.getLnurlPayInvoice(lnurl, amount, note) + Try.Success(invoice) + } catch (e: Exception) { + Try.Failure(e) + } + } + + suspend fun resolveAddress(username: String, domain: String, amount: MilliSatoshi, note: String?): Try> { + return when (val offer = dnsAddress.resolveBip353Offer(username, domain)) { + null -> when (val lnurl = resolveLnUrl(username, domain, amount, note)) { + is Try.Success -> Try.Success(Either.Left(lnurl.result)) + is Try.Failure -> Try.Failure(lnurl.error) + } + else -> Try.Success(Either.Right(offer)) + } + } + +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/LnurlHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/LnurlHandler.kt new file mode 100644 index 0000000..369bf6d --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/LnurlHandler.kt @@ -0,0 +1,240 @@ +package fr.acinq.lightning.bin.payments.lnurl + +import co.touchlab.kermit.Logger +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.bin.payments.lnurl.helpers.LnurlPayParser +import fr.acinq.lightning.bin.payments.lnurl.helpers.LnurlAuthSigner +import fr.acinq.lightning.bin.payments.lnurl.models.* +import fr.acinq.lightning.bin.payments.lnurl.models.Lnurl.Tag +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.debug +import fr.acinq.lightning.logging.error +import fr.acinq.lightning.payment.PaymentRequest +import fr.acinq.lightning.utils.msat +import io.ktor.client.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.utils.io.charsets.* +import kotlinx.serialization.json.* + +/** + * Executes and processes Lnurls into actionable objects. + * + * First step is to parse/execute an url and get more information, depending on the [Lnurl.Tag]. + * + * Then depending on the type: + * - lnurl-pay: query the service based on the parameters provided to obtain a bolt11 invoice + * - lnurl-withdraw: query the service with an invoice we generated based on the parameters they provided, and wait to be paid. + * - lnurl-auth: sign a k1 secret with our key (derived) and query the service with that sig/pubkey + */ +class LnurlHandler( + loggerFactory: LoggerFactory, + private val keyManager: LocalKeyManager +) { + private val log = loggerFactory.newLogger(this::class) + + // We don't want ktor to break when receiving non-2xx response + private val httpClient: HttpClient by lazy { + HttpClient { + install(ContentNegotiation) { + json(json = Json { ignoreUnknownKeys = true }) + expectSuccess = false + } + } + } + + /** Executes an HTTP GET request on the provided url and parses the JSON response into an [Lnurl.Qualified] object. */ + suspend fun executeLnurl(url: Url): Lnurl.Qualified { + val response: HttpResponse = try { + httpClient.get(url) + } catch (err: Throwable) { + throw LnurlError.RemoteFailure.CouldNotConnect(origin = url.host) + } + try { + val json = processHttpResponse(response, log) + return parseLnurlJson(url, json) + } catch (e: Exception) { + when (e) { + is LnurlError -> throw e + else -> throw LnurlError.RemoteFailure.Unreadable(url.host) + } + } + } + + /** + * Execute an HTTP GET request to obtain a [LnurlPay.InvoiceToPay] from a [LnurlPay.PaymentParameters]. May throw a + * [LnurlError.RemoteFailure] or a [LnurlError.Pay.BadInvoice] error. + * + * @param payParameters the description of the payment as provided by the service. + * @param amount the amount that the user is willing to pay to settle the [LnurlPay.Intent]. + * @param comment an optional string commenting the payment and sent to the service. + */ + suspend fun getLnurlPayInvoice( + payParameters: LnurlPay.PaymentParameters, + amount: MilliSatoshi, + comment: String? + ): LnurlPay.InvoiceToPay { + + val builder = URLBuilder(payParameters.callback) + builder.parameters.append(name = "amount", value = amount.msat.toString()) + if (!comment.isNullOrEmpty()) { + builder.parameters.append(name = "comment", value = comment) + } + val callback = builder.build() + val origin = callback.host + + val response: HttpResponse = try { + httpClient.get(callback) + } catch (err: Throwable) { + throw LnurlError.RemoteFailure.CouldNotConnect(origin) + } + + val json = processHttpResponse(response, log) + val invoice = LnurlPayParser.parseInvoiceToPay(payParameters, origin, json) + + // SPECS: LN WALLET verifies that the amount in the provided invoice equals the amount previously specified by user. + if (amount != invoice.invoice.amount) { + log.error { "rejecting invoice from $origin with amount_invoice=${invoice.invoice.amount} requested_amount=$amount" } + throw LnurlError.Pay.BadInvoice.InvalidAmount(origin) + } + + return invoice + } + + /** + * Send an invoice to a lnurl service following a [LnurlWithdraw] request. + * Throw [LnurlError.RemoteFailure]. + */ + suspend fun sendWithdrawInvoice( + lnurlWithdraw: LnurlWithdraw, + paymentRequest: PaymentRequest + ): JsonObject { + + val builder = URLBuilder(lnurlWithdraw.callback) + builder.parameters.append(name = "k1", value = lnurlWithdraw.k1) + builder.parameters.append(name = "pr", value = paymentRequest.write()) + val callback = builder.build() + val origin = callback.host + + val response: HttpResponse = try { + httpClient.get(callback) + } catch (err: Throwable) { + throw LnurlError.RemoteFailure.CouldNotConnect(origin) + } + + // SPECS: even if the response is an error, the invoice may still be paid by the service + // we still parse the response to see what's up. + return processHttpResponse(response, log) + } + + suspend fun signAndSendAuthRequest( + auth: LnurlAuth, + ) { + val key = LnurlAuthSigner.getAuthLinkingKey( + localKeyManager = keyManager, + serviceUrl = auth.initialUrl, + ) + val (pubkey, signedK1) = LnurlAuthSigner.signChallenge(auth.k1, key) + + val builder = URLBuilder(auth.initialUrl) + builder.parameters.append(name = "sig", value = signedK1.toHex()) + builder.parameters.append(name = "key", value = pubkey.toString()) + val url = builder.build() + + val response: HttpResponse = try { + httpClient.get(url) + } catch (t: Throwable) { + throw LnurlError.RemoteFailure.CouldNotConnect(origin = url.host) + } + + processHttpResponse(response, log) // throws on any/all non-success + } + + /** + * Processes an HTTP response from a lnurl service and returns a [JsonObject]. + * + * Throw: + * - [LnurlError.RemoteFailure.Code] if service returns a non-2XX code + * - [LnurlError.RemoteFailure.Unreadable] if response is not valid JSON + * - [LnurlError.RemoteFailure.Detailed] if service reports an internal error message (`{ status: "error", reason: "..." }`) + */ + suspend fun processHttpResponse(response: HttpResponse, logger: Logger): JsonObject { + val url = response.request.url + val json: JsonObject = try { + // From the LUD-01 specs: + // > HTTP Status Codes and Content-Type: + // > Neither status codes or any HTTP Header has any meaning. Servers may use + // > whatever they want. Clients should ignore them [...] and just parse the + // > response body as JSON, then interpret it accordingly. + Json.decodeFromString(response.bodyAsText(Charsets.UTF_8)) + } catch (e: Exception) { + logger.error(e) { "unhandled response from url=$url: " } + throw LnurlError.RemoteFailure.Unreadable(url.host) + } + + logger.debug { "lnurl service=${url.host} returned response=${json.toString().take(100)}" } + return if (json["status"]?.jsonPrimitive?.content?.trim()?.equals("error", true) == true) { + val errorMessage = json["reason"]?.jsonPrimitive?.content?.trim() ?: "" + if (errorMessage.isNotEmpty()) { + logger.error { "lnurl service=${url.host} returned error=$errorMessage" } + throw LnurlError.RemoteFailure.Detailed(url.host, errorMessage.take(90).replace("<", "")) + } else if (!response.status.isSuccess()) { + throw LnurlError.RemoteFailure.Code(url.host, response.status) + } else { + throw LnurlError.RemoteFailure.Unreadable(url.host) + } + } else { + json + } + } + + /** Converts a lnurl JSON response to a [Lnurl.Qualified] object. */ + fun parseLnurlJson(url: Url, json: JsonObject): Lnurl.Qualified { + val callback = URLBuilder(json["callback"]?.jsonPrimitive?.content ?: throw LnurlError.Invalid.MissingCallback).build() + if (!callback.protocol.isSecure()) throw LnurlError.Invalid.UnsafeResource + val tag = json["tag"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: throw LnurlError.Invalid.NoTag + return when (tag) { + Tag.Withdraw.label -> { + val k1 = json["k1"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: throw LnurlError.Withdraw.MissingK1 + val minWithdrawable = json["minWithdrawable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat + ?: json["minWithdrawable"]?.jsonPrimitive?.long?.takeIf { it > 0 }?.msat + ?: 0.msat + val maxWithdrawable = json["maxWithdrawable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat + ?: json["maxWithdrawable"]?.jsonPrimitive?.long?.takeIf { it > 0 }?.msat + ?: minWithdrawable + val dDesc = json["defaultDescription"]?.jsonPrimitive?.content ?: "" + LnurlWithdraw( + initialUrl = url, + callback = callback, + k1 = k1, + defaultDescription = dDesc, + minWithdrawable = minWithdrawable.coerceAtMost(maxWithdrawable), + maxWithdrawable = maxWithdrawable + ) + } + Tag.Pay.label -> { + val minSendable = json["minSendable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat + ?: json["minSendable"]?.jsonPrimitive?.longOrNull?.takeIf { it > 0 }?.msat + ?: throw LnurlError.Pay.BadParameters.InvalidMin + val maxSendable = json["maxSendable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat + ?: json["maxSendable"]?.jsonPrimitive?.longOrNull?.coerceAtLeast(minSendable.msat)?.msat + ?: throw LnurlError.Pay.BadParameters.MissingMax + val metadata = LnurlPayParser.parseMetadata(json["metadata"]?.jsonPrimitive?.content ?: throw LnurlError.Pay.BadParameters.MissingMetadata) + val maxCommentLength = json["commentAllowed"]?.jsonPrimitive?.longOrNull?.takeIf { it > 0 } + LnurlPay.PaymentParameters( + initialUrl = url, + callback = callback, + minSendable = minSendable, + maxSendable = maxSendable, + metadata = metadata, + maxCommentLength = maxCommentLength + ) + } + else -> throw LnurlError.Invalid.UnhandledTag(tag) + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/helpers/LnurlAuthSigner.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/helpers/LnurlAuthSigner.kt new file mode 100644 index 0000000..e775e65 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/helpers/LnurlAuthSigner.kt @@ -0,0 +1,61 @@ +package fr.acinq.lightning.bin.payments.lnurl.helpers + +import fr.acinq.bitcoin.* +import fr.acinq.bitcoin.crypto.Digest +import fr.acinq.bitcoin.crypto.Pack +import fr.acinq.bitcoin.crypto.hmac +import fr.acinq.lightning.crypto.LocalKeyManager +import io.ktor.http.* + +object LnurlAuthSigner { + + /** Signs the challenge with the key provided and returns the public key and the DER-encoded signed data. */ + fun signChallenge( + challenge: String, + key: PrivateKey + ): Pair { + return key.publicKey() to Crypto.compact2der(Crypto.sign(data = ByteVector32.fromValidHex(challenge), privateKey = key)) + } + + /** + * Returns a key to sign a lnurl-auth challenge. This key is derived from the wallet's master key. The derivation + * path depends on the domain provided and the type of the key. + */ + fun getAuthLinkingKey( + localKeyManager: LocalKeyManager, + serviceUrl: Url, + ): PrivateKey { + val hashingKeyPath = KeyPath("m/138'/0") + val hashingKey = localKeyManager.derivePrivateKey(hashingKeyPath) + // the domain used for the derivation path may not be the full domain name. + val path = getDerivationPathForDomain( + domain = serviceUrl.host, + hashingKey = hashingKey.privateKey.value.toByteArray() + ) + return localKeyManager.derivePrivateKey(path).privateKey + } + + /** + * Returns lnurl-auth path derivation, as described in spec: + * https://github.com/fiatjaf/lnurl-rfc/blob/luds/05.md + * + * Test vectors exist for path derivation. + */ + private fun getDerivationPathForDomain( + domain: String, + hashingKey: ByteArray + ): KeyPath { + val fullHash = Digest.sha256().hmac( + key = hashingKey, + data = domain.encodeToByteArray(), + blockSize = 64 + ) + require(fullHash.size >= 16) { "domain hash must be at least 16 bytes" } + val path1 = fullHash.sliceArray(IntRange(0, 3)).let { Pack.int32BE(it, 0) }.toUInt() + val path2 = fullHash.sliceArray(IntRange(4, 7)).let { Pack.int32BE(it, 0) }.toUInt() + val path3 = fullHash.sliceArray(IntRange(8, 11)).let { Pack.int32BE(it, 0) }.toUInt() + val path4 = fullHash.sliceArray(IntRange(12, 15)).let { Pack.int32BE(it, 0) }.toUInt() + + return KeyPath("m/138'/$path1/$path2/$path3/$path4") + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/helpers/LnurlParser.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/helpers/LnurlParser.kt new file mode 100644 index 0000000..a00ecda --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/helpers/LnurlParser.kt @@ -0,0 +1,126 @@ +package fr.acinq.lightning.bin.payments.lnurl.helpers + +import fr.acinq.bitcoin.Bech32 +import fr.acinq.lightning.bin.payments.lnurl.models.Lnurl +import fr.acinq.lightning.bin.payments.lnurl.models.Lnurl.Request +import fr.acinq.lightning.bin.payments.lnurl.models.Lnurl.Tag +import fr.acinq.lightning.bin.payments.lnurl.models.LnurlAuth +import fr.acinq.lightning.bin.payments.lnurl.models.LnurlError +import io.ktor.http.* + +/** Helper for parsing a string into an [Lnurl] object. */ +object LnurlParser { + + private val prefixes = listOf("lightning://", "lightning:", "bitcoin://", "bitcoin:", "lnurl://", "lnurl:") + + /** + * Remove the prefix from the input, if any. Trimming is done in a case-insensitive manner because often QR codes will + * use upper-case for the prefix, such as LIGHTNING:LNURL1... + */ + private fun trimPrefixes( + input: String, + ): String { + val matchingPrefix = prefixes.firstOrNull { input.startsWith(it, ignoreCase = true) } + return if (matchingPrefix != null) { + input.drop(matchingPrefix.length) + } else { + input + } + } + + /** + * Attempts to extract a [Lnurl] from a string. + * + * @param source can be a bech32 lnurl, a non-bech32 lnurl, or a lightning address. + * @return a [LnurlAuth] if the source is a login lnurl, or an [Lnurl.Request] if it is a payment/withdrawal lnurl. + * + * Throws an exception if the source is malformed or invalid. + */ + fun extractLnurl(source: String): Lnurl { + val input = trimPrefixes(source) + val url: Url = try { + parseBech32Url(input) + } catch (bech32Ex: Exception) { + try { + if (lud17Schemes.any { input.startsWith(it, ignoreCase = true) }) { + parseNonBech32Lud17(input) + } else { + parseNonBech32Http(input) + } + } catch (nonBech32Ex: Exception) { + throw LnurlError.Invalid.MalformedUrl(cause = nonBech32Ex) + } + } + val tag = url.parameters["tag"]?.let { + when (it) { + Tag.Auth.label -> Tag.Auth + Tag.Withdraw.label -> Tag.Withdraw + Tag.Pay.label -> Tag.Pay + else -> null // ignore unknown tags and handle the lnurl as a `request` to be executed immediately + } + } + return when (tag) { + Tag.Auth -> { + val k1 = url.parameters["k1"] + if (k1.isNullOrBlank()) { + throw LnurlError.Auth.MissingK1 + } else { + LnurlAuth(url, k1) + } + } + else -> Request(url, tag) + } + } + + /** Lnurls are originally bech32 encoded. If unreadable, throw an exception. */ + private fun parseBech32Url(source: String): Url { + val (_, data) = Bech32.decode(source) + val payload = Bech32.five2eight(data, 0).decodeToString() + val url = URLBuilder(payload).build() + if (!url.protocol.isSecure()) throw LnurlError.Invalid.UnsafeResource + return url + } + + /** Lnurls sometimes hide in regular http urls, under the lightning parameter. */ + private fun parseNonBech32Http(source: String): Url { + val urlBuilder = URLBuilder(source) + val lightningParam = urlBuilder.parameters["lightning"] + return if (!lightningParam.isNullOrBlank()) { + // this url contains a lnurl fallback which takes priority - and must be bech32 encoded + parseBech32Url(lightningParam) + } else { + if (!urlBuilder.protocol.isSecure()) throw LnurlError.Invalid.UnsafeResource + urlBuilder.build() + } + } + + private val lud17Schemes = listOf( + "phoenix:lnurlp://", "phoenix:lnurlp:", + "lnurlp://", "lnurlp:", + "phoenix:lnurlw://", "phoenix:lnurlw:", + "lnurlw://", "lnurlw:", + "phoenix:keyauth://", "phoenix:keyauth:", + "keyauth://", "keyauth:", + ) + + /** Converts LUD-17 lnurls (using a custom scheme like lnurlc:, lnurlp:, keyauth:) into a regular http url. */ + private fun parseNonBech32Lud17(source: String): Url { + val matchingPrefix = lud17Schemes.firstOrNull { source.startsWith(it, ignoreCase = true) } + val stripped = if (matchingPrefix != null) { + source.drop(matchingPrefix.length) + } else { + throw IllegalArgumentException("source does not use a lud17 scheme: $source") + } + return URLBuilder(stripped).apply { + encodedPath.split("/", ignoreCase = true, limit = 2).let { + this.host = it.first() + this.encodedPath = "/${it.drop(1).joinToString()}" + } + protocol = if (this.host.endsWith(".onion")) { + URLProtocol.HTTP + } else { + URLProtocol.HTTPS + } + }.build() + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/helpers/LnurlPayParser.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/helpers/LnurlPayParser.kt new file mode 100644 index 0000000..f46df0a --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/helpers/LnurlPayParser.kt @@ -0,0 +1,136 @@ +package fr.acinq.lightning.bin.payments.lnurl.helpers + +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.utils.Try +import fr.acinq.lightning.bin.payments.lnurl.models.Lnurl +import fr.acinq.lightning.bin.payments.lnurl.models.LnurlError +import fr.acinq.lightning.bin.payments.lnurl.models.LnurlPay +import fr.acinq.lightning.bin.payments.lnurl.models.LnurlPay.PaymentParameters +import fr.acinq.lightning.bin.payments.lnurl.models.LnurlPay.InvoiceToPay +import fr.acinq.lightning.payment.Bolt11Invoice +import io.ktor.http.* +import io.ktor.util.* +import kotlinx.serialization.json.* + +/** Parsers specific to lnurl-pay. */ +object LnurlPayParser { + + /** Unknown elements in the json returned by the lnurl-pay service must be ignored. */ + private val format: Json = Json { ignoreUnknownKeys = true } + + /** Parses json into a [LnurlPay.InvoiceToPay] object. Throws an [LnurlError.Pay.BadInvoice] exception if unreadable. */ + fun parseInvoiceToPay( + intent: PaymentParameters, + origin: String, + json: JsonObject + ): InvoiceToPay { + try { + val pr = json["pr"]?.jsonPrimitive?.content ?: throw LnurlError.Pay.BadInvoice.Malformed(origin, "missing invoice parameter") + val invoice = when (val res = Bolt11Invoice.read(pr)) { + is Try.Success -> res.result + is Try.Failure -> throw LnurlError.Pay.BadInvoice.Malformed(origin, "$pr [${res.error.message ?: res.error::class.toString()}]") + } + + val successAction = parseSuccessAction(origin, json) + return InvoiceToPay(intent.initialUrl, invoice, successAction) + } catch (t: Throwable) { + when (t) { + is LnurlError.Pay.BadInvoice -> throw t + else -> throw LnurlError.Pay.BadInvoice.Malformed(origin, "unknown error") + } + } + } + + private fun parseSuccessAction( + origin: String, + json: JsonObject + ): InvoiceToPay.SuccessAction? { + val obj = try { + json["successAction"]?.jsonObject // throws on Non-JsonObject (e.g. JsonNull) + } catch (t: Throwable) { + null + } ?: return null + + return when (obj["tag"]?.jsonPrimitive?.content) { + InvoiceToPay.SuccessAction.Tag.Message.label -> { + val message = obj["message"]?.jsonPrimitive?.content ?: return null + if (message.isBlank() || message.length > 144) { + throw LnurlError.Pay.BadInvoice.Malformed(origin, "success.message: bad length") + } + InvoiceToPay.SuccessAction.Message(message) + } + InvoiceToPay.SuccessAction.Tag.Url.label -> { + val description = obj["description"]?.jsonPrimitive?.content ?: return null + if (description.length > 144) { + throw LnurlError.Pay.BadInvoice.Malformed(origin, "success.url.description: bad length") + } + val urlStr = obj["url"]?.jsonPrimitive?.content ?: return null + val url = Url(urlStr) + InvoiceToPay.SuccessAction.Url(description, url) + } + InvoiceToPay.SuccessAction.Tag.Aes.label -> { + val description = obj["description"]?.jsonPrimitive?.content ?: return null + if (description.length > 144) { + throw LnurlError.Pay.BadInvoice.Malformed(origin, "success.aes.description: bad length") + } + val ciphertextStr = obj["ciphertext"]?.jsonPrimitive?.content ?: return null + val ciphertext = ByteVector(ciphertextStr.decodeBase64Bytes()) + if (ciphertext.size() > (4 * 1024)) { + throw LnurlError.Pay.BadInvoice.Malformed(origin, "success.aes.ciphertext: bad length") + } + val ivStr = obj["iv"]?.jsonPrimitive?.content ?: return null + if (ivStr.length != 24) { + throw LnurlError.Pay.BadInvoice.Malformed(origin, "success.aes.iv: bad length") + } + val iv = ByteVector(ivStr.decodeBase64Bytes()) + InvoiceToPay.SuccessAction.Aes(description, ciphertext = ciphertext, iv = iv) + } + else -> null + } + } + + /** Decode a serialized [Lnurl.Pay.Metadata] object. */ + fun parseMetadata(raw: String): PaymentParameters.Metadata { + return try { + val array = format.decodeFromString(raw) + var plainText: String? = null + var longDesc: String? = null + var imagePng: String? = null + var imageJpg: String? = null + var identifier: String? = null + var email: String? = null + val unknown = mutableListOf() + array.forEach { + try { + when (it.jsonArray[0].jsonPrimitive.content) { + "text/plain" -> plainText = it.jsonArray[1].jsonPrimitive.content + "text/long-desc" -> longDesc = it.jsonArray[1].jsonPrimitive.content + "image/png;base64" -> imagePng = it.jsonArray[1].jsonPrimitive.content + "image/jpeg;base64" -> imageJpg = it.jsonArray[1].jsonPrimitive.content + "text/identifier" -> identifier = it.jsonArray[1].jsonPrimitive.content + "text/email" -> email = it.jsonArray[1].jsonPrimitive.content + else -> unknown.add(it) + } + } catch (e: Exception) { + Logger.w("LnurlPay") { "could not decode raw lnurlpay-meta=$it: ${e.message}" } + } + } + PaymentParameters.Metadata( + raw = raw, + plainText = plainText!!, + longDesc = longDesc, + imagePng = imagePng, + imageJpg = imageJpg, + identifier = identifier, + email = email, + unknown = unknown.takeIf { it.isNotEmpty() }?.let { + JsonArray(it.toList()) + } + ) + } catch (e: Exception) { + Logger.e("LnurlPay") { "could not decode raw lnurlpay-meta=$raw: ${e.message}" } + throw LnurlError.Pay.BadParameters.InvalidMetadata(raw) + } + } +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/Lnurl.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/Lnurl.kt new file mode 100644 index 0000000..448ca67 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/Lnurl.kt @@ -0,0 +1,36 @@ +package fr.acinq.lightning.bin.payments.lnurl.models + +import io.ktor.http.* + +/** + * This class describes the various types of Lnurls supported by phoenixd: + * - auth + * - pay + * - withdraw + * + * It also contains the possible errors related to the Lnurl flow: + * errors that break the specs, or errors raised when the data returned + * by the Lnurl service are not valid. + */ +sealed interface Lnurl { + + val initialUrl: Url + + /** + * Most lnurls must be executed first to be of any use, as they don't contain any info by themselves. + */ + data class Request(override val initialUrl: Url, val tag: Tag?) : Lnurl + + /** + * Qualified lnurls objects contain all the necessary data needed from the lnurl service for the user + * to decide how to proceed. + */ + sealed interface Qualified : Lnurl + + /** Tag associated to a Lnurl, usually in a `?tag=` parameter. */ + enum class Tag(val label: String) { + Auth("login"), + Withdraw("withdrawRequest"), + Pay("payRequest") + } +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/LnurlAuth.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/LnurlAuth.kt new file mode 100644 index 0000000..10a1e44 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/LnurlAuth.kt @@ -0,0 +1,27 @@ +package fr.acinq.lightning.bin.payments.lnurl.models + +import io.ktor.http.* + +data class LnurlAuth( + override val initialUrl: Url, + val k1: String +) : Lnurl.Qualified { + + enum class Action { + Register, Login, Link, Auth + } + + val action = initialUrl.parameters["action"]?.let { action -> + when (action.lowercase()) { + "register" -> Action.Register + "login" -> Action.Login + "link" -> Action.Link + "auth" -> Action.Auth + else -> null + } + } + + override fun toString(): String { + return "LnurlAuth(action=$action, initialUrl=$initialUrl)".take(100) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/LnurlError.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/LnurlError.kt new file mode 100644 index 0000000..e53519e --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/LnurlError.kt @@ -0,0 +1,52 @@ +package fr.acinq.lightning.bin.payments.lnurl.models + +import io.ktor.http.* + +sealed class LnurlError(override val message: String? = null) : RuntimeException(message) { + val details: String by lazy { "Lnurl error=${message ?: this::class.simpleName ?: "N/A"}" } + + sealed class Invalid(override val message: String) : LnurlError() { + data class MalformedUrl(override val cause: Throwable?) : Invalid("cannot be parsed as a bech32 or as a human readable lnurl") + data object NoTag : Invalid("no tag field found") + data class UnhandledTag(val tag: String) : Invalid("unhandled tag=$tag") + data object UnsafeResource : Invalid("resource should be https") + data object MissingCallback : Invalid("missing callback in metadata response") + } + + sealed class RemoteFailure(override val message: String) : LnurlError(message) { + abstract val origin: String + + data class CouldNotConnect(override val origin: String) : RemoteFailure("could not connect to $origin") + data class Unreadable(override val origin: String) : RemoteFailure("unreadable response from $origin") + data class Detailed(override val origin: String, val reason: String) : RemoteFailure("error=$reason from $origin") + data class Code(override val origin: String, val code: HttpStatusCode) : RemoteFailure("error code=$code from $origin") + } + + sealed class Auth(override val message: String?) : LnurlError(message) { + data object MissingK1 : Auth("missing k1 parameter") + } + + sealed class Withdraw(override val message: String?) : LnurlError(message) { + data object MissingK1 : Withdraw("missing k1 parameter") + } + + sealed class Pay : LnurlError() { + sealed class BadParameters(override val message: String?) : LnurlError(message) { + data object InvalidMin : BadParameters("invalid minimum amount") + data object MissingMax : BadParameters("missing maximum amount parameter") + data object MissingMetadata : BadParameters("missing metadata parameter") + data class InvalidMetadata(val meta: String) : BadParameters("invalid metadata=$meta") + } + + sealed class BadInvoice(override val message: String?) : LnurlError(message) { + abstract val origin: String + + data class Malformed( + override val origin: String, + val context: String + ) : BadInvoice("malformed invoice: $context") + + data class InvalidAmount(override val origin: String) : BadInvoice("invoice's amount doesn't match input") + } + } +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/LnurlPay.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/LnurlPay.kt new file mode 100644 index 0000000..bea5c5a --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/LnurlPay.kt @@ -0,0 +1,84 @@ +package fr.acinq.lightning.bin.payments.lnurl.models + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.payment.Bolt11Invoice +import io.ktor.http.* +import kotlinx.serialization.json.* + +sealed class LnurlPay : Lnurl.Qualified { + + /** + * Response from a lnurl service to describe what kind of payment is expected. + * First step of the lnurl-pay flow. + */ + data class PaymentParameters( + override val initialUrl: Url, + val callback: Url, + val minSendable: MilliSatoshi, + val maxSendable: MilliSatoshi, + val metadata: Metadata, + val maxCommentLength: Long? + ) : LnurlPay() { + data class Metadata( + val raw: String, + val plainText: String, + val longDesc: String?, + val imagePng: String?, // base64 encoded png + val imageJpg: String?, // base64 encoded jpg + val identifier: String?, + val email: String?, + val unknown: JsonArray? + ) { + val lnid: String? by lazy { email ?: identifier } + + override fun toString(): String { + return "Metadata(plainText=$plainText, longDesc=${longDesc?.take(50)}, identifier=$identifier, email=$email, imagePng=${imagePng?.take(10)}, imageJpg=${imageJpg?.take(10)})" + } + } + + override fun toString(): String { + return "PaymentParameters(minSendable=$minSendable, maxSendable=$maxSendable, metadata=$metadata, maxCommentLength=$maxCommentLength, initialUrl=$initialUrl, callback=$callback)".take(100) + } + } + + /** + * Invoice returned by a lnurl service after user states what they want to pay. + * Second step of the lnurl-payment flow. + */ + data class InvoiceToPay( + override val initialUrl: Url, + val invoice: Bolt11Invoice, + val successAction: SuccessAction? + ) : LnurlPay() { + sealed class SuccessAction { + data class Message( + val message: String + ) : SuccessAction() + + data class Url( + val description: String, + val url: io.ktor.http.Url + ) : SuccessAction() + + data class Aes( + val description: String, + val ciphertext: ByteVector, + val iv: ByteVector + ) : SuccessAction() { + data class Decrypted( + val description: String, + val plaintext: String + ) + } + + enum class Tag(val label: String) { + Message("message"), + Url("url"), + Aes("aes") + } + } + } +} + + diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/LnurlWithdraw.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/LnurlWithdraw.kt new file mode 100644 index 0000000..d436a55 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/payments/lnurl/models/LnurlWithdraw.kt @@ -0,0 +1,17 @@ +package fr.acinq.lightning.bin.payments.lnurl.models + +import fr.acinq.lightning.MilliSatoshi +import io.ktor.http.* + +data class LnurlWithdraw( + override val initialUrl: Url, + val callback: Url, + val k1: String, + val defaultDescription: String, + val minWithdrawable: MilliSatoshi, + val maxWithdrawable: MilliSatoshi +) : Lnurl.Qualified { + override fun toString(): String { + return "LnurlWithdraw(defaultDescription='$defaultDescription', minWithdrawable=$minWithdrawable, maxWithdrawable=$maxWithdrawable, initialUrl=$initialUrl, callback=$callback)".take(100) + } +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt b/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt index 3343070..bddbf71 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt @@ -20,6 +20,9 @@ import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.BuildVersions import fr.acinq.lightning.bin.conf.readConfFile import fr.acinq.lightning.bin.datadir +import fr.acinq.lightning.bin.payments.lnurl.helpers.LnurlParser +import fr.acinq.lightning.bin.payments.lnurl.models.Lnurl +import fr.acinq.lightning.bin.payments.lnurl.models.LnurlAuth import fr.acinq.lightning.bin.payments.Parser import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.utils.UUID @@ -57,6 +60,9 @@ fun main(args: Array) = PayLnAddress(), DecodeInvoice(), DecodeOffer(), + LnurlPay(), + LnurlWithdraw(), + LnurlAuth(), SendToAddress(), CloseChannel() ) @@ -258,7 +264,7 @@ class PayOffer : PhoenixCliCommand(name = "payoffer", help = "Pay a Lightning of } } -class PayLnAddress : PhoenixCliCommand(name = "paylnaddress", help = "Pay a Lightning address (BIP353)", printHelpOnEmptyArgs = true) { +class PayLnAddress : PhoenixCliCommand(name = "paylnaddress", help = "Pay a Lightning address (BIP353 or LNURL)", printHelpOnEmptyArgs = true) { private val amountSat by option("--amountSat").long().required() private val address by option("--address").required().check { Parser.parseEmailLikeAddress(it) != null } private val message by option("--message").help { "Optional payer note" } @@ -298,6 +304,54 @@ class DecodeOffer : PhoenixCliCommand(name = "decodeoffer", help = "Decode a Lig } } +class LnurlPay : PhoenixCliCommand(name = "lnurlpay", help = "Pay a LNURL", printHelpOnEmptyArgs = true) { + private val amountSat by option("--amountSat").long() + private val lnurl by option("--lnurl").required().check { + val url = LnurlParser.extractLnurl(it) + url is Lnurl.Request && (url.tag == Lnurl.Tag.Pay || url.tag == null) + } + private val message by option("--message").help { "Optional comment" } + override suspend fun httpRequest(): HttpResponse = commonOptions.httpClient.use { + it.submitForm( + url = (commonOptions.baseUrl / "lnurlpay").toString(), + formParameters = parameters { + amountSat?.let { append("amountSat", amountSat.toString()) } + append("lnurl", lnurl) + message?.let { append("message", message.toString()) } + } + ) + } +} + +class LnurlWithdraw : PhoenixCliCommand(name = "lnurlwithdraw", help = "Withdraw funds from a LNURL service", printHelpOnEmptyArgs = true) { + private val lnurl by option("--lnurl").required().check { + val url = LnurlParser.extractLnurl(it) + url is Lnurl.Request && (url.tag == Lnurl.Tag.Withdraw || url.tag == null) + } + override suspend fun httpRequest(): HttpResponse = commonOptions.httpClient.use { + it.submitForm( + url = (commonOptions.baseUrl / "lnurlwithdraw").toString(), + formParameters = parameters { + append("lnurl", lnurl) + } + ) + } +} + +class LnurlAuth : PhoenixCliCommand(name = "lnurlauth", help = "Authenticate on a LNURL service", printHelpOnEmptyArgs = true) { + private val lnurl by option("--lnurl").required().check { + LnurlParser.extractLnurl(it) is LnurlAuth + } + override suspend fun httpRequest(): HttpResponse = commonOptions.httpClient.use { + it.submitForm( + url = (commonOptions.baseUrl / "lnurlauth").toString(), + formParameters = parameters { + append("lnurl", lnurl) + } + ) + } +} + class SendToAddress : PhoenixCliCommand(name = "sendtoaddress", help = "Send to a Bitcoin address", printHelpOnEmptyArgs = true) { private val amountSat by option("--amountSat").long().required() private val address by option("--address").required().check { runCatching { Base58Check.decode(it) }.isSuccess || runCatching { Bech32.decodeWitnessAddress(it) }.isSuccess }