From 18ade318cdd5cfe24727fbb6843803759a9eac3a Mon Sep 17 00:00:00 2001 From: Pierre-Marie Padiou Date: Tue, 2 Jul 2024 14:19:24 +0200 Subject: [PATCH] Add BOLT12 support (#60) BOLT12 introduces "offers", which are reusable payment requests (a.k.a. static invoices). Add the following phoenix-cli/api methods: - `getoffer` - `payoffer` - `decodeinvoice` (supersedes #56) - `decodeoffer` --------- Co-authored-by: Dominique Padiou <5765435+dpad85@users.noreply.github.com> --- .../kotlin/fr/acinq/lightning/bin/Api.kt | 34 +++++++++- .../kotlin/fr/acinq/lightning/bin/Main.kt | 1 + .../bin/db/payments/IncomingOriginType.kt | 17 ++++- .../payments/LightningOutgoingDetailsType.kt | 32 ++++++++-- .../lightning/bin/json/JsonSerializers.kt | 5 +- .../fr/acinq/lightning/cli/PhoenixCli.kt | 63 ++++++++++++++++++- 6 files changed, 143 insertions(+), 9 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt index d31254f..a05062d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt @@ -8,6 +8,7 @@ import fr.acinq.bitcoin.utils.Either import fr.acinq.bitcoin.utils.toEither import fr.acinq.lightning.BuildVersions import fr.acinq.lightning.Lightning.randomBytes32 +import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.NodeParams import fr.acinq.lightning.bin.db.SqlitePaymentsDb import fr.acinq.lightning.bin.db.WalletPaymentId @@ -25,6 +26,7 @@ import fr.acinq.lightning.io.Peer import fr.acinq.lightning.io.WrappedChannelCommand import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.utils.* +import fr.acinq.lightning.wire.OfferTypes import io.ktor.client.* import io.ktor.client.request.* import io.ktor.http.* @@ -43,16 +45,20 @@ import io.ktor.server.routing.* import io.ktor.server.websocket.* import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch +import kotlinx.serialization.ExperimentalSerializationApi 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) { + @OptIn(ExperimentalSerializationApi::class) fun Application.module() { val json = Json { prettyPrint = true isLenient = true + explicitNulls = false serializersModule = fr.acinq.lightning.json.JsonSerializers.json.serializersModule } @@ -131,6 +137,9 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va } call.respond(GeneratedInvoice(invoice.amount?.truncateToSatoshi(), invoice.paymentHash, serialized = invoice.write())) } + get("getoffer") { + call.respond(nodeParams.defaultOffer(peer.walletParams.trampolineNode.id).encode()) + } get("payments/incoming") { val listAll = call.parameters["all"]?.toBoolean() ?: false // by default, only list incoming payments that have been received val externalId = call.parameters["externalId"] // may filter incoming payments by an external id @@ -180,9 +189,30 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va when (val event = peer.payInvoice(amount, 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 -> TODO() + is fr.acinq.lightning.io.OfferNotPaid -> error("unreachable code") } } + post("payoffer") { + val formParameters = call.receiveParameters() + val overrideAmount = formParameters["amountSat"]?.let { it.toLongOrNull() ?: invalidType("amountSat", "integer") }?.sat?.toMilliSatoshi() + val offer = formParameters.getOffer("offer") + val amount = (overrideAmount ?: offer.amount) ?: missing("amountSat") + when (val event = peer.payOffer(amount, offer, randomKey(), 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)) + } + } + post("decodeinvoice") { + val formParameters = call.receiveParameters() + val invoice = formParameters.getInvoice("invoice") + call.respond(invoice) + } + post("decodeoffer") { + val formParameters = call.receiveParameters() + val offer = formParameters.getOffer("offer") + call.respond(offer) + } post("sendtoaddress") { val res = kotlin.runCatching { val formParameters = call.receiveParameters() @@ -268,6 +298,8 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va private fun Parameters.getInvoice(argName: String): Bolt11Invoice = getString(argName).let { invoice -> Bolt11Invoice.read(invoice).getOrElse { invalidType(argName, "bolt11invoice") } } + private fun Parameters.getOffer(argName: String): OfferTypes.Offer = getString(argName).let { invoice -> OfferTypes.Offer.decode(invoice).getOrElse { invalidType(argName, "offer") } } + private fun Parameters.getLong(argName: String): Long = ((this[argName] ?: missing(argName)).toLongOrNull()) ?: invalidType(argName, "integer") private fun Parameters.getOptionalLong(argName: String): Long? = this[argName]?.let { it.toLongOrNull() ?: invalidType(argName, "integer") } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt index 544614f..0bbc9e7 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt @@ -242,6 +242,7 @@ class Phoenixd : CliktCommand() { liquidityPolicy = MutableStateFlow(liquidityPolicy), ) consoleLog(cyan("nodeid: ${nodeParams.nodeId}")) + consoleLog(cyan("offer: ${nodeParams.defaultOffer(lsp.walletParams.trampolineNode.id)}")) val driver = createAppDbDriver(datadir, chain, nodeParams.nodeId) val database = PhoenixDatabase( diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingOriginType.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingOriginType.kt index f856362..071c49c 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingOriginType.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingOriginType.kt @@ -17,10 +17,12 @@ @file:UseSerializers( OutpointSerializer::class, ByteVector32Serializer::class, + ByteVectorSerializer::class, ) package fr.acinq.lightning.bin.db.payments +import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.OutPoint import fr.acinq.bitcoin.TxId @@ -28,7 +30,9 @@ import fr.acinq.lightning.bin.db.payments.DbTypesHelper.decodeBlob import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer +import fr.acinq.lightning.bin.db.serializers.v1.ByteVectorSerializer import fr.acinq.lightning.bin.db.serializers.v1.OutpointSerializer +import fr.acinq.lightning.payment.OfferPaymentMetadata import io.ktor.utils.io.charsets.* import io.ktor.utils.io.core.* import kotlinx.serialization.* @@ -39,6 +43,7 @@ enum class IncomingOriginTypeVersion { INVOICE_V0, SWAPIN_V0, ONCHAIN_V0, + OFFER_V0, } sealed class IncomingOriginData { @@ -58,12 +63,21 @@ sealed class IncomingOriginData { data class V0(@Serializable val txId: ByteVector32, val outpoints: List<@Serializable OutPoint>) : SwapIn() } + sealed class Offer : IncomingOriginData() { + @Serializable + data class V0(@Serializable val encodedMetadata: ByteVector) : Offer() + } + + companion object { fun deserialize(typeVersion: IncomingOriginTypeVersion, blob: ByteArray): IncomingPayment.Origin = decodeBlob(blob) { json, format -> when (typeVersion) { IncomingOriginTypeVersion.INVOICE_V0 -> format.decodeFromString(json).let { IncomingPayment.Origin.Invoice(Bolt11Invoice.read(it.paymentRequest).get()) } IncomingOriginTypeVersion.SWAPIN_V0 -> format.decodeFromString(json).let { IncomingPayment.Origin.SwapIn(it.address) } IncomingOriginTypeVersion.ONCHAIN_V0 -> format.decodeFromString(json).let { IncomingPayment.Origin.OnChain(TxId(it.txId), it.outpoints.toSet()) } + IncomingOriginTypeVersion.OFFER_V0 -> format.decodeFromString(json).let { + IncomingPayment.Origin.Offer(metadata = OfferPaymentMetadata.decode(it.encodedMetadata)) + } } } } @@ -76,5 +90,6 @@ fun IncomingPayment.Origin.mapToDb(): Pair Json.encodeToString(IncomingOriginData.SwapIn.V0(address)).toByteArray(Charsets.UTF_8) is IncomingPayment.Origin.OnChain -> IncomingOriginTypeVersion.ONCHAIN_V0 to Json.encodeToString(IncomingOriginData.OnChain.V0(txId.value, localInputs.toList())).toByteArray(Charsets.UTF_8) - is IncomingPayment.Origin.Offer -> TODO() + is IncomingPayment.Origin.Offer -> IncomingOriginTypeVersion.OFFER_V0 to + Json.encodeToString(IncomingOriginData.Offer.V0(metadata.encode())).toByteArray(Charsets.UTF_8) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/LightningOutgoingDetailsType.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/LightningOutgoingDetailsType.kt index da6e824..c1d917b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/LightningOutgoingDetailsType.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/LightningOutgoingDetailsType.kt @@ -21,12 +21,13 @@ package fr.acinq.lightning.bin.db.payments -import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer import fr.acinq.lightning.bin.db.serializers.v1.SatoshiSerializer import fr.acinq.lightning.db.LightningOutgoingPayment import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.payment.Bolt12Invoice import io.ktor.utils.io.charsets.* import io.ktor.utils.io.core.* import kotlinx.serialization.Serializable @@ -38,6 +39,7 @@ import kotlinx.serialization.json.Json enum class LightningOutgoingDetailsTypeVersion { NORMAL_V0, SWAPOUT_V0, + BLINDED_V0 } sealed class LightningOutgoingDetailsData { @@ -52,12 +54,33 @@ sealed class LightningOutgoingDetailsData { data class V0(val address: String, val paymentRequest: String, @Serializable val swapOutFee: Satoshi) : SwapOut() } + sealed class Blinded : LightningOutgoingDetailsData() { + @Serializable + data class V0(val paymentRequest: String, val payerKey: String) : Blinded() + } + companion object { /** Deserialize the details of an outgoing payment. Return null if the details is for a legacy channel closing payment (see [deserializeLegacyClosingDetails]). */ fun deserialize(typeVersion: LightningOutgoingDetailsTypeVersion, blob: ByteArray): LightningOutgoingPayment.Details = DbTypesHelper.decodeBlob(blob) { json, format -> when (typeVersion) { - LightningOutgoingDetailsTypeVersion.NORMAL_V0 -> format.decodeFromString(json).let { LightningOutgoingPayment.Details.Normal(Bolt11Invoice.read(it.paymentRequest).get()) } - LightningOutgoingDetailsTypeVersion.SWAPOUT_V0 -> format.decodeFromString(json).let { LightningOutgoingPayment.Details.SwapOut(it.address, Bolt11Invoice.read(it.paymentRequest).get(), it.swapOutFee) } + LightningOutgoingDetailsTypeVersion.NORMAL_V0 -> format.decodeFromString(json).let { + LightningOutgoingPayment.Details.Normal( + paymentRequest = Bolt11Invoice.read(it.paymentRequest).get() + ) + } + LightningOutgoingDetailsTypeVersion.SWAPOUT_V0 -> format.decodeFromString(json).let { + LightningOutgoingPayment.Details.SwapOut( + address = it.address, + paymentRequest = Bolt11Invoice.read(it.paymentRequest).get(), + swapOutFee = it.swapOutFee + ) + } + LightningOutgoingDetailsTypeVersion.BLINDED_V0 -> format.decodeFromString(json).let { + LightningOutgoingPayment.Details.Blinded( + paymentRequest = Bolt12Invoice.fromString(it.paymentRequest).get(), + payerKey = PrivateKey.fromHex(it.payerKey), + ) + } } } } @@ -68,5 +91,6 @@ fun LightningOutgoingPayment.Details.mapToDb(): Pair LightningOutgoingDetailsTypeVersion.SWAPOUT_V0 to Json.encodeToString(LightningOutgoingDetailsData.SwapOut.V0(address, paymentRequest.write(), swapOutFee)).toByteArray(Charsets.UTF_8) - is LightningOutgoingPayment.Details.Blinded -> TODO() + is LightningOutgoingPayment.Details.Blinded -> LightningOutgoingDetailsTypeVersion.BLINDED_V0 to + Json.encodeToString(LightningOutgoingDetailsData.Blinded.V0(paymentRequest.write(), payerKey.toHex())).toByteArray(Charsets.UTF_8) } 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 95a4802..6dde917 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt @@ -96,8 +96,9 @@ sealed class ApiType { @Serializable @SerialName("payment_failed") - data class PaymentFailed(val paymentHash: ByteVector32, val reason: String) : ApiType() { - constructor(event: fr.acinq.lightning.io.PaymentNotSent) : this(event.request.paymentHash, event.reason.explain().fold({ it.toString() }, { it.toString() })) + data class PaymentFailed(val paymentHash: ByteVector32?, val offerId: ByteVector32?, val reason: String) : ApiType() { + constructor(event: fr.acinq.lightning.io.PaymentNotSent) : this(paymentHash = event.request.paymentHash, offerId = null, reason = event.reason.explain().fold({ it.toString() }, { it.toString() })) + constructor(event: fr.acinq.lightning.io.OfferNotPaid) : this(paymentHash = null, offerId = event.request.offer.offerId, event.reason.toString()) } @Serializable diff --git a/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt b/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt index c737379..c0e38dc 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt @@ -22,6 +22,7 @@ import fr.acinq.lightning.bin.conf.readConfFile import fr.acinq.lightning.bin.datadir import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.wire.OfferTypes import io.ktor.client.* import io.ktor.client.plugins.auth.* import io.ktor.client.plugins.auth.providers.* @@ -39,7 +40,23 @@ import kotlinx.serialization.json.Json fun main(args: Array) = PhoenixCli() .versionOption(BuildVersions.phoenixdVersion, names = setOf("--version", "-v")) - .subcommands(GetInfo(), GetBalance(), ListChannels(), GetOutgoingPayment(), ListOutgoingPayments(), GetIncomingPayment(), ListIncomingPayments(), CreateInvoice(), PayInvoice(), SendToAddress(), CloseChannel()) + .subcommands( + GetInfo(), + GetBalance(), + ListChannels(), + GetOutgoingPayment(), + ListOutgoingPayments(), + GetIncomingPayment(), + ListIncomingPayments(), + CreateInvoice(), + GetOffer(), + PayInvoice(), + PayOffer(), + DecodeInvoice(), + DecodeOffer(), + SendToAddress(), + CloseChannel() + ) .main(args) data class HttpConf(val baseUrl: Url, val httpClient: HttpClient) @@ -196,6 +213,12 @@ class CreateInvoice : PhoenixCliCommand(name = "createinvoice", help = "Create a } } +class GetOffer : PhoenixCliCommand(name = "getoffer", help = "Return a Lightning offer (static invoice)") { + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.get(url = commonOptions.baseUrl / "getoffer") + } +} + class PayInvoice : PhoenixCliCommand(name = "payinvoice", help = "Pay a Lightning invoice", printHelpOnEmptyArgs = true) { private val amountSat by option("--amountSat").long() private val invoice by option("--invoice").required().check { Bolt11Invoice.read(it).isSuccess } @@ -210,6 +233,44 @@ class PayInvoice : PhoenixCliCommand(name = "payinvoice", help = "Pay a Lightnin } } +class PayOffer : PhoenixCliCommand(name = "payoffer", help = "Pay a Lightning offer", printHelpOnEmptyArgs = true) { + private val amountSat by option("--amountSat").long() + private val invoice by option("--offer").required().check { OfferTypes.Offer.decode(it).isSuccess } + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.submitForm( + url = (commonOptions.baseUrl / "payoffer").toString(), + formParameters = parameters { + amountSat?.let { append("amountSat", amountSat.toString()) } + append("offer", invoice) + } + ) + } +} + +class DecodeInvoice : PhoenixCliCommand(name = "decodeinvoice", help = "Decode a Lightning invoice", printHelpOnEmptyArgs = true) { + private val invoice by option("--invoice").required().check { Bolt11Invoice.read(it).isSuccess } + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.submitForm( + url = (commonOptions.baseUrl / "decodeinvoice").toString(), + formParameters = parameters { + append("invoice", invoice) + } + ) + } +} + +class DecodeOffer : PhoenixCliCommand(name = "decodeoffer", help = "Decode a Lightning offer", printHelpOnEmptyArgs = true) { + private val invoice by option("--offer").required().check { OfferTypes.Offer.decode(it).isSuccess } + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.submitForm( + url = (commonOptions.baseUrl / "decodeoffer").toString(), + formParameters = parameters { + append("offer", invoice) + } + ) + } +} + 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 }