Skip to content

Commit

Permalink
Add support for LNURL (#68)
Browse files Browse the repository at this point in the history
New methods:
- `lnurlauth`
- `lnurlpay`
- `lnurlwithdraw`

Note that `paylnaddress` now supports BIP353 and LNURL addresses. It will try with BIP353 first and fallback to LNURL.
  • Loading branch information
pm47 authored Jul 11, 2024
1 parent 71cfabe commit dd0193f
Show file tree
Hide file tree
Showing 14 changed files with 1,010 additions and 16 deletions.
125 changes: 111 additions & 14 deletions src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,34 @@ 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
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
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
Expand All @@ -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<ApiEvent>, 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<ApiEvent>,
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
Expand Down Expand Up @@ -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") {
Expand All @@ -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()
Expand Down Expand Up @@ -331,4 +425,7 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va

private fun Parameters.getEmailLikeAddress(argName: String): Pair<String, String> = 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)
}
2 changes: 1 addition & 1 deletion src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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()
)
}
}
Original file line number Diff line number Diff line change
@@ -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<LnurlPay.InvoiceToPay> {
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<Either<LnurlPay.InvoiceToPay, OfferTypes.Offer>> {
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))
}
}

}
Loading

0 comments on commit dd0193f

Please sign in to comment.