308 lines
16 KiB
Kotlin
Raw Normal View History

2024-03-08 18:44:24 +01:00
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
2024-03-08 18:44:24 +01:00
import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.Lightning.randomKey
2024-03-08 18:44:24 +01:00
import fr.acinq.lightning.NodeParams
2024-03-11 17:58:42 +01:00
import fr.acinq.lightning.bin.db.SqlitePaymentsDb
import fr.acinq.lightning.bin.db.WalletPaymentId
2024-03-08 18:44:24 +01:00
import fr.acinq.lightning.bin.json.ApiType.*
import fr.acinq.lightning.bin.json.ApiType.IncomingPayment
import fr.acinq.lightning.bin.json.ApiType.OutgoingPayment
2024-03-08 18:44:24 +01:00
import fr.acinq.lightning.blockchain.fee.FeeratePerByte
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.ChannelCommand
2024-03-21 14:24:05 +01:00
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
2024-03-08 18:44:24 +01:00
import fr.acinq.lightning.io.Peer
import fr.acinq.lightning.io.WrappedChannelCommand
import fr.acinq.lightning.payment.Bolt11Invoice
2024-03-11 17:58:42 +01:00
import fr.acinq.lightning.utils.*
import fr.acinq.lightning.wire.OfferTypes
2024-03-08 18:44:24 +01:00
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.http.content.*
2024-03-08 18:44:24 +01:00
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
2024-03-08 18:44:24 +01:00
import kotlinx.serialization.json.Json
import okio.ByteString.Companion.encodeUtf8
import kotlin.time.Duration.Companion.seconds
2024-03-08 18:44:24 +01:00
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) {
2024-03-08 18:44:24 +01:00
@OptIn(ExperimentalSerializationApi::class)
2024-03-08 18:44:24 +01:00
fun Application.module() {
val json = Json {
prettyPrint = true
isLenient = true
explicitNulls = false
2024-03-08 18:44:24 +01:00
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)
}
2024-03-08 18:44:24 +01:00
}
install(Authentication) {
basic {
validate { credentials ->
if (credentials.password == password) {
UserIdPrincipal(credentials.name)
} else {
null
}
}
}
}
routing {
authenticate {
get("getinfo") {
val info = NodeInfo(
nodeId = nodeParams.nodeId,
2024-03-21 11:47:37 +01:00
channels = peer.channels.values.map { Channel.from(it) },
chain = nodeParams.chain.name.lowercase(),
blockHeight = peer.currentTipFlow.value,
2024-03-21 11:47:37 +01:00
version = BuildVersions.phoenixdVersion
2024-03-08 18:44:24 +01:00
)
call.respond(info)
}
get("getbalance") {
val balance = peer.channels.values
.filterIsInstance<ChannelStateWithCommitments>()
2024-03-21 11:47:37 +01:00
.filterNot { it is Closing || it is Closed }
2024-03-08 18:44:24 +01:00
.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()
2024-03-21 14:24:05 +01:00
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)
}
2024-03-08 18:44:24 +01:00
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)
}
2024-03-11 17:58:42 +01:00
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))
2024-03-11 17:58:42 +01:00
} ?: 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)
}
2024-03-11 17:58:42 +01:00
get("payments/outgoing/{uuid}") {
val uuid = call.parameters.getUUID("uuid")
paymentDb.getLightningOutgoingPayment(uuid)?.let {
call.respond(OutgoingPayment(it))
} ?: call.respond(HttpStatusCode.NotFound)
}
2024-03-08 18:44:24 +01:00
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)) {
2024-03-08 18:44:24 +01:00
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))
2024-03-08 18:44:24 +01:00
}
}
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)
}
2024-03-08 18:44:24 +01:00
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) {
2024-03-20 12:54:41 +01:00
println("onError ${closeReason.await()?.message}")
2024-03-08 18:44:24 +01:00
}
}
}
}
webhookUrl?.let { url ->
val client = HttpClient {
2024-03-08 18:44:24 +01:00
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())
}
}
}
2024-03-08 18:44:24 +01:00
launch {
eventsFlow.collect { event ->
client.post(url) {
contentType(ContentType.Application.Json)
setBody(event)
}
}
}
}
}
2024-03-11 17:58:42 +01:00
private val paymentDb: SqlitePaymentsDb by lazy { peer.db.payments as SqlitePaymentsDb }
2024-03-08 18:44:24 +01:00
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)
2024-03-08 18:44:24 +01:00
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") }
2024-03-11 17:58:42 +01:00
private fun Parameters.getUUID(argName: String): UUID = getString(argName).let { uuid -> kotlin.runCatching { UUID.fromString(uuid) }.getOrNull() ?: invalidType(argName, "uuid") }
2024-03-08 18:44:24 +01:00
private fun Parameters.getAddressAndConvertToScript(argName: String): ByteVector = Script.write(Bitcoin.addressToPublicKeyScript(nodeParams.chainHash, getString(argName)).right ?: badRequest("Invalid address")).toByteVector()
2024-03-08 18:44:24 +01:00
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") } }
2024-03-08 18:44:24 +01:00
private fun Parameters.getLong(argName: String): Long = ((this[argName] ?: missing(argName)).toLongOrNull()) ?: invalidType(argName, "integer")
2024-03-21 14:24:05 +01:00
private fun Parameters.getOptionalLong(argName: String): Long? = this[argName]?.let { it.toLongOrNull() ?: invalidType(argName, "integer") }
2024-03-08 18:44:24 +01:00
}