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>
This commit is contained in:
Pierre-Marie Padiou 2024-07-02 14:19:24 +02:00 committed by GitHub
parent 4a7e2a4921
commit 18ade318cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 143 additions and 9 deletions

View File

@ -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<ApiEvent>, 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") }

View File

@ -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(

View File

@ -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<Invoice.V0>(json).let { IncomingPayment.Origin.Invoice(Bolt11Invoice.read(it.paymentRequest).get()) }
IncomingOriginTypeVersion.SWAPIN_V0 -> format.decodeFromString<SwapIn.V0>(json).let { IncomingPayment.Origin.SwapIn(it.address) }
IncomingOriginTypeVersion.ONCHAIN_V0 -> format.decodeFromString<OnChain.V0>(json).let { IncomingPayment.Origin.OnChain(TxId(it.txId), it.outpoints.toSet()) }
IncomingOriginTypeVersion.OFFER_V0 -> format.decodeFromString<Offer.V0>(json).let {
IncomingPayment.Origin.Offer(metadata = OfferPaymentMetadata.decode(it.encodedMetadata))
}
}
}
}
@ -76,5 +90,6 @@ fun IncomingPayment.Origin.mapToDb(): Pair<IncomingOriginTypeVersion, ByteArray>
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)
}

View File

@ -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<Normal.V0>(json).let { LightningOutgoingPayment.Details.Normal(Bolt11Invoice.read(it.paymentRequest).get()) }
LightningOutgoingDetailsTypeVersion.SWAPOUT_V0 -> format.decodeFromString<SwapOut.V0>(json).let { LightningOutgoingPayment.Details.SwapOut(it.address, Bolt11Invoice.read(it.paymentRequest).get(), it.swapOutFee) }
LightningOutgoingDetailsTypeVersion.NORMAL_V0 -> format.decodeFromString<Normal.V0>(json).let {
LightningOutgoingPayment.Details.Normal(
paymentRequest = Bolt11Invoice.read(it.paymentRequest).get()
)
}
LightningOutgoingDetailsTypeVersion.SWAPOUT_V0 -> format.decodeFromString<SwapOut.V0>(json).let {
LightningOutgoingPayment.Details.SwapOut(
address = it.address,
paymentRequest = Bolt11Invoice.read(it.paymentRequest).get(),
swapOutFee = it.swapOutFee
)
}
LightningOutgoingDetailsTypeVersion.BLINDED_V0 -> format.decodeFromString<Blinded.V0>(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<LightningOutgoingDetailsTyp
Json.encodeToString(LightningOutgoingDetailsData.Normal.V0(paymentRequest.write())).toByteArray(Charsets.UTF_8)
is LightningOutgoingPayment.Details.SwapOut -> 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)
}

View File

@ -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

View File

@ -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<String>) =
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 }