Pierre-Marie Padiou 18ade318cd
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>
2024-07-02 14:19:24 +02:00

308 lines
16 KiB
Kotlin

package fr.acinq.lightning.bin
import fr.acinq.bitcoin.Bitcoin
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.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
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.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.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.*
import io.ktor.http.content.*
import io.ktor.serialization.kotlinx.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.engine.*
import io.ktor.server.plugins.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import io.ktor.server.response.*
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
}
install(ContentNegotiation) {
json(json)
}
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(json)
timeoutMillis = 10_000
pingPeriodMillis = 10_000
}
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respondText(text = cause.message ?: "", status = defaultExceptionStatusCode(cause) ?: HttpStatusCode.InternalServerError)
}
status(HttpStatusCode.Unauthorized) { call, status ->
call.respondText(text = "Invalid authentication (use basic auth with the http password set in phoenix.conf)", status = status)
}
status(HttpStatusCode.MethodNotAllowed) { call, status ->
call.respondText(text = "Invalid http method (use the correct GET/POST)", status = status)
}
status(HttpStatusCode.NotFound) { call, status ->
call.respondText(text = "Unknown endpoint (check api doc)", status = status)
}
}
install(Authentication) {
basic {
validate { credentials ->
if (credentials.password == password) {
UserIdPrincipal(credentials.name)
} else {
null
}
}
}
}
routing {
authenticate {
get("getinfo") {
val info = NodeInfo(
nodeId = nodeParams.nodeId,
channels = peer.channels.values.map { Channel.from(it) },
chain = nodeParams.chain.name.lowercase(),
blockHeight = peer.currentTipFlow.value,
version = BuildVersions.phoenixdVersion
)
call.respond(info)
}
get("getbalance") {
val balance = peer.channels.values
.filterIsInstance<ChannelStateWithCommitments>()
.filterNot { it is Closing || it is Closed }
.map { it.commitments.active.first().availableBalanceForSend(it.commitments.params, it.commitments.changes) }
.sum().truncateToSatoshi()
call.respond(Balance(balance, nodeParams.feeCredit.value))
}
get("listchannels") {
call.respond(peer.channels.values.toList())
}
post("createinvoice") {
val formParameters = call.receiveParameters()
val amount = formParameters.getOptionalLong("amountSat")?.sat
val maxDescriptionSize = 128
val description = formParameters["description"]
?.also { if (it.length > maxDescriptionSize) badRequest("Request parameter description is too long (max $maxDescriptionSize characters)") }
val descriptionHash = formParameters.getOptionalByteVector32("descriptionHash")
val eitherDesc = when {
description != null && descriptionHash == null -> Either.Left(description)
description == null && descriptionHash != null -> Either.Right(descriptionHash)
else -> badRequest("Must provide either a description or descriptionHash")
}
val invoice = peer.createInvoice(randomBytes32(), amount?.toMilliSatoshi(), eitherDesc)
formParameters["externalId"]?.takeUnless { it.isBlank() }?.let { externalId ->
paymentDb.metadataQueries.insertExternalId(WalletPaymentId.IncomingPaymentId(invoice.paymentHash), externalId)
}
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
val from = call.parameters.getOptionalLong("from") ?: 0L
val to = call.parameters.getOptionalLong("to") ?: currentTimestampMillis()
val limit = call.parameters.getOptionalLong("limit") ?: 20
val offset = call.parameters.getOptionalLong("offset") ?: 0
val payments = if (externalId.isNullOrBlank()) {
paymentDb.listIncomingPayments(from, to, limit, offset, listAll)
} else {
paymentDb.listIncomingPaymentsForExternalId(externalId, from, to, limit, offset, listAll)
}.map { (payment, externalId) ->
IncomingPayment(payment, externalId)
}
call.respond(payments)
}
get("payments/incoming/{paymentHash}") {
val paymentHash = call.parameters.getByteVector32("paymentHash")
paymentDb.getIncomingPayment(paymentHash)?.let {
val metadata = paymentDb.metadataQueries.get(WalletPaymentId.IncomingPaymentId(paymentHash))
call.respond(IncomingPayment(it, metadata?.externalId))
} ?: call.respond(HttpStatusCode.NotFound)
}
get("payments/outgoing") {
val listAll = call.parameters["all"]?.toBoolean() ?: false // by default, only list outgoing payments that have been successfully sent, or are pending
val from = call.parameters.getOptionalLong("from") ?: 0L
val to = call.parameters.getOptionalLong("to") ?: currentTimestampMillis()
val limit = call.parameters.getOptionalLong("limit") ?: 20
val offset = call.parameters.getOptionalLong("offset") ?: 0
val payments = paymentDb.listLightningOutgoingPayments(from, to, limit, offset, listAll).map {
OutgoingPayment(it)
}
call.respond(payments)
}
get("payments/outgoing/{uuid}") {
val uuid = call.parameters.getUUID("uuid")
paymentDb.getLightningOutgoingPayment(uuid)?.let {
call.respond(OutgoingPayment(it))
} ?: call.respond(HttpStatusCode.NotFound)
}
post("payinvoice") {
val formParameters = call.receiveParameters()
val overrideAmount = formParameters["amountSat"]?.let { it.toLongOrNull() ?: invalidType("amountSat", "integer") }?.sat?.toMilliSatoshi()
val invoice = formParameters.getInvoice("invoice")
val amount = (overrideAmount ?: invoice.amount) ?: missing("amountSat")
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 -> 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()
val amount = formParameters.getLong("amountSat").sat
val scriptPubKey = formParameters.getAddressAndConvertToScript("address")
val feerate = FeeratePerKw(FeeratePerByte(formParameters.getLong("feerateSatByte").sat))
peer.spliceOut(amount, scriptPubKey, feerate)
}.toEither()
when (res) {
is Either.Right -> when (val r = res.value) {
is ChannelCommand.Commitment.Splice.Response.Created -> call.respondText(r.fundingTxId.toString())
is ChannelCommand.Commitment.Splice.Response.Failure -> call.respondText(r.toString())
else -> call.respondText("no channel available")
}
is Either.Left -> call.respondText(res.value.message.toString())
}
}
post("closechannel") {
val formParameters = call.receiveParameters()
val channelId = formParameters.getByteVector32("channelId")
val scriptPubKey = formParameters.getAddressAndConvertToScript("address")
val feerate = FeeratePerKw(FeeratePerByte(formParameters.getLong("feerateSatByte").sat))
peer.send(WrappedChannelCommand(channelId, ChannelCommand.Close.MutualClose(scriptPubKey, ClosingFeerates(feerate))))
call.respondText("ok")
}
webSocket("/websocket") {
try {
eventsFlow.collect { sendSerialized(it) }
} catch (e: Throwable) {
println("onError ${closeReason.await()?.message}")
}
}
}
}
webhookUrl?.let { url ->
val client = HttpClient {
install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) {
json(json = Json {
prettyPrint = true
isLenient = true
})
}
}
client.sendPipeline.intercept(HttpSendPipeline.State) {
when (val body = context.body) {
is TextContent -> {
val bodyBytes = body.text.encodeUtf8()
val secretBytes = webhookSecret.encodeUtf8()
val sig = bodyBytes.hmacSha256(secretBytes)
context.headers.append("X-Phoenix-Signature", sig.hex())
}
}
}
launch {
eventsFlow.collect { event ->
client.post(url) {
contentType(ContentType.Application.Json)
setBody(event)
}
}
}
}
}
private val paymentDb: SqlitePaymentsDb by lazy { peer.db.payments as SqlitePaymentsDb }
private fun missing(argName: String): Nothing = throw MissingRequestParameterException(argName)
private fun invalidType(argName: String, typeName: String): Nothing = throw ParameterConversionException(argName, typeName)
private fun badRequest(message: String): Nothing = throw BadRequestException(message)
private fun Parameters.getString(argName: String): String = (this[argName] ?: missing(argName))
private fun Parameters.getByteVector32(argName: String): ByteVector32 = getString(argName).let { hex -> kotlin.runCatching { ByteVector32.fromValidHex(hex) }.getOrNull() ?: invalidType(argName, "hex32") }
private fun Parameters.getOptionalByteVector32(argName: String): ByteVector32? = this[argName]?.let { hex -> kotlin.runCatching { ByteVector32.fromValidHex(hex) }.getOrNull() ?: invalidType(argName, "hex32") }
private fun Parameters.getUUID(argName: String): UUID = getString(argName).let { uuid -> kotlin.runCatching { UUID.fromString(uuid) }.getOrNull() ?: invalidType(argName, "uuid") }
private fun Parameters.getAddressAndConvertToScript(argName: String): ByteVector = Script.write(Bitcoin.addressToPublicKeyScript(nodeParams.chainHash, getString(argName)).right ?: badRequest("Invalid address")).toByteVector()
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") }
}