diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f971d5b..8bab457e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ ktlint = "11.3.1" ktor = "2.3.7" lightsparkCore = "0.6.0" lightsparkCrypto = "0.6.0" -uma = "1.2.1" +uma = "1.3.0" mavenPublish = "0.25.2" mockitoCore = "5.5.0" taskTree = "2.1.1" diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/ReceivingVasp.kt b/umaserverdemo/src/main/kotlin/com/lightspark/ReceivingVasp.kt index 31a57d88..c8f87085 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/ReceivingVasp.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/ReceivingVasp.kt @@ -4,7 +4,18 @@ import com.lightspark.sdk.ClientConfig import com.lightspark.sdk.LightsparkCoroutinesClient import com.lightspark.sdk.auth.AccountApiTokenAuthProvider import com.lightspark.sdk.model.Node +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.ApplicationCall import io.ktor.server.application.call import io.ktor.server.plugins.origin @@ -31,12 +42,19 @@ import me.uma.UmaInvoiceCreator import me.uma.UmaProtocolHelper import me.uma.UnsupportedVersionException import me.uma.protocol.CounterPartyDataOptions +import me.uma.protocol.InvoiceCurrency import me.uma.protocol.KycStatus import me.uma.protocol.LnurlpResponse import me.uma.protocol.PayRequest import me.uma.protocol.createCounterPartyDataOptions import me.uma.protocol.createPayeeData import me.uma.protocol.identifier +import java.util.UUID +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.plus +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive + class ReceivingVasp( private val config: UmaConfig, @@ -46,15 +64,131 @@ class ReceivingVasp( private val nonceCache = InMemoryNonceCache(Clock.System.now().epochSeconds) private val coroutineScope = CoroutineScope(Dispatchers.IO) private lateinit var senderUmaVersion: String + private val httpClient = HttpClient { + install(ContentNegotiation) { + json( + Json { + isLenient = true + }, + ) + } + } suspend fun createInvoice(call: ApplicationCall): String { + val (status, data) = createUmaInvoice(call) + if (status != HttpStatusCode.OK) { + call.respond(status, data) + return data + } else { + call.respond(data) + } return "OK" } suspend fun createAndSendInvoice(call: ApplicationCall): String { + val senderUma = call.parameters["senderUma"] ?: run { + call.respond(HttpStatusCode.BadRequest, "SenderUma not provided.") + return "SenderUma not provided." + } + val (status, data) = createUmaInvoice(call, senderUma) + if (status != HttpStatusCode.OK) { + call.respond(status, data) + return data + } + val senderComponents = senderUma.split("@") + val sendingVaspDomain = senderComponents.getOrNull(1) ?: run { + call.respond(HttpStatusCode.BadRequest, "Invalid senderUma.") + return "Invalid senderUma." + } + val wellKnownConfiguration = "http://$sendingVaspDomain/.well-known/uma-configuration" + val umaEndpoint = try { + val umaConfigResponse = httpClient.get(wellKnownConfiguration) + if (umaConfigResponse.status != HttpStatusCode.OK) { + call.respond( + HttpStatusCode.FailedDependency, + "failed to fetch request / pay endpoint at $wellKnownConfiguration", + ) + return "failed to fetch request / pay endpoint at $wellKnownConfiguration" + } + Json.decodeFromString( + umaConfigResponse.bodyAsText(), + )["uma_request_endpoint"]?.jsonPrimitive?.content + } catch (e: Exception) { + call.respond( + HttpStatusCode.FailedDependency, + "failed to fetch request / pay endpoint at $wellKnownConfiguration", + ) + return "failed to fetch request / pay endpoint at $wellKnownConfiguration" + } + if (umaEndpoint == null) { + call.respond(HttpStatusCode.FailedDependency, "failed to fetch $wellKnownConfiguration") + return "failed to fetch $wellKnownConfiguration" + } + val response = try { + httpClient.post(umaEndpoint) { + contentType(ContentType.Application.Json) + setBody(parameter("invoice", data)) + } + } catch (e: Exception) { + call.respond(HttpStatusCode.FailedDependency, "failed to fetch $umaEndpoint") + return "failed to fetch $umaEndpoint" + } + if (response.status != HttpStatusCode.OK) { + call.respond(HttpStatusCode.InternalServerError, "Payreq to Sending Vasp: ${response.status}") + return "Payreq to sending vasp failed: ${response.status}" + } + call.respond(response.body()) return "OK" } + private fun createUmaInvoice( + call: ApplicationCall, senderUma: String? = null + ): Pair { + val amount = try { + call.parameters["amount"]?.toLong() ?: run { + return HttpStatusCode.BadRequest to "Amount not provided." + } + } catch (e: NumberFormatException) { + return HttpStatusCode.BadRequest to "Amount not parsable as number." + } + + val currency = call.parameters["currencyCode"]?.let { currencyCode -> + // check if we support this currency code. + getReceivingCurrencies(UMA_VERSION_STRING).firstOrNull { + it.code == currencyCode + } ?: run { + return HttpStatusCode.BadRequest to "Unsupported CurrencyCode $currencyCode." + } + } ?: run { + return HttpStatusCode.BadRequest to "CurrencyCode not provided." + } + + val expiresIn2Days = Clock.System.now().plus(2, DateTimeUnit.HOUR*24) //? + + val receiverUma = "${config.username}:${getReceivingVaspDomain(call)}" + + val invoice = uma.getInvoice( + receiverUma = receiverUma, + invoiceUUID = UUID.randomUUID().toString(), + amount = amount, + receivingCurrency = InvoiceCurrency( + currency.code, currency.name, currency.symbol, currency.decimals + ), + expiration = expiresIn2Days.toEpochMilliseconds(), + isSubjectToTravelRule = true, + requiredPayerData = createCounterPartyDataOptions( + "name" to false, + "email" to false, + "compliance" to true, + "identifier" to true, + ), + callback = getLnurlpCallback(call), // structured the same, going to /api/uma/payreq/{user_id} + privateSigningKey = config.umaSigningPrivKey, + senderUma = senderUma + ) + return HttpStatusCode.OK to invoice.toBech32() + } + suspend fun handleLnurlp(call: ApplicationCall): String { val username = call.parameters["username"] @@ -355,11 +489,11 @@ fun Routing.registerReceivingVaspRoutes(receivingVasp: ReceivingVasp) { call.debugLog(receivingVasp.handleUmaPayreq(call)) } - get("/api/uma/create_invoice") { + post("/api/uma/create_invoice") { call.debugLog(receivingVasp.createInvoice(call)); } - get("/api/uma/create_and_send_invoice") { + post("/api/uma/create_and_send_invoice") { call.debugLog(receivingVasp.createAndSendInvoice(call)) } } diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt b/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt index 2d2b3eb3..e03e98ad 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt @@ -57,7 +57,7 @@ fun Application.configureRouting( call.respond( HttpStatusCode.OK, buildJsonObject { - put("uma_request_endpoint", "$scheme://$domain/api/uma/request_pay_invoice") + put("uma_request_endpoint", "$scheme://$domain/api/uma/request_invoice_payment") put( "uma_major_versions", buildJsonArray {