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>
308 lines
16 KiB
Kotlin
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") }
|
|
|
|
}
|