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:
parent
4a7e2a4921
commit
18ade318cd
@ -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") }
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 }
|
||||
|
Loading…
x
Reference in New Issue
Block a user