From 90906013726ddc6fc668e6fd80c727098da75a0b Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Tue, 22 Aug 2023 08:58:56 -0700 Subject: [PATCH 1/5] WIP: starting vasp1 --- .../src/main/kotlin/com/lightspark/Vasp1.kt | 129 ++++++++++++++++++ .../kotlin/com/lightspark/plugins/Routing.kt | 15 +- 2 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt new file mode 100644 index 00000000..df24b0a3 --- /dev/null +++ b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt @@ -0,0 +1,129 @@ +package com.lightspark + +import com.lightspark.sdk.LightsparkCoroutinesClient +import com.lightspark.sdk.uma.LnurlpResponse +import com.lightspark.sdk.uma.UmaProtocolHelper +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.respond + +class Vasp1( + private val config: UmaConfig, + private val uma: UmaProtocolHelper, + private val lightsparkClient: LightsparkCoroutinesClient, +) { + private val httpClient = HttpClient() + + suspend fun handleClientUmaLookup(call: ApplicationCall): String { + val receiverAddress = call.parameters["receiver"] + if (receiverAddress == null) { + call.respond(HttpStatusCode.BadRequest, "Receiver not provided.") + return "Receiver not provided." + } + + val addressParts = receiverAddress.split("@") + if (addressParts.size != 2) { + call.respond(HttpStatusCode.BadRequest, "Invalid receiver address.") + return "Invalid receiver address." + } + val receiverId = addressParts[0] + val receiverVasp = addressParts[1] + val signingKey = config.umaSigningPrivKey + + val lnurlpRequest = uma.getSignedLnurlpRequestUrl( + signingPrivateKey = signingKey, + receiverAddress = receiverAddress, + // TODO: This should be configurable. + senderVaspDomain = "localhost:8080", + trStatus = true, + ) + + val response = httpClient.get(lnurlpRequest) + if (response == null) { + call.respond(HttpStatusCode.FailedDependency, "Failed to fetch lnurlp response.") + return "Failed to fetch lnurlp response." + } + + if (response.status != HttpStatusCode.OK) { + call.respond(HttpStatusCode.FailedDependency, "Failed to fetch lnurlp response.") + return "Failed to fetch lnurlp response." + } + + val lnurlpResponse = try { + response.body() + } catch (e: Exception) { + call.respond(HttpStatusCode.FailedDependency, "Failed to parse lnurlp response.") + return "Failed to parse lnurlp response." + } + + val vasp2PubKey = try { + uma.fetchPublicKeysForVasp(receiverVasp) + } catch (e: Exception) { + call.respond(HttpStatusCode.FailedDependency, "Failed to fetch public keys.") + return "Failed to fetch public keys." + } + + try { + uma.verifyLnurlpResponseSignature(lnurlpResponse, vasp2PubKey) + } catch (e: Exception) { + call.respond(HttpStatusCode.BadRequest, "Failed to verify lnurlp response signature.") + return "Failed to verify lnurlp response signature." + } + + // TODO(Jeremy): Save the request info to cache. + + call.respond( + mapOf( + "currencies" to lnurlpResponse.currencies, + "minSendSats" to lnurlpResponse.minSendable, + "maxSendSats" to lnurlpResponse.maxSendable, + "callbackUuid" to "TODO", + // You might not actually send this to a client in practice. + "isReceiverKYCd" to lnurlpResponse.compliance.isKYCd, + ), + ) + + return "OK" + } + + suspend fun handleClientUmaPayReq(call: ApplicationCall): String { + val callbackUuid = call.parameters["callbackUuid"] + if (callbackUuid == null) { + call.respond(HttpStatusCode.BadRequest, "Callback UUID not provided.") + return "Callback UUID not provided." + } + + val amount = call.request.queryParameters["amount"]?.let { it.toLongOrNull() } + if (amount == null || amount <= 0) { + call.respond(HttpStatusCode.BadRequest, "Amount invalid or not provided.") + return "Amount invalid or not provided." + } + + val currencyCode = call.request.queryParameters["currencyCode"] + if (currencyCode == null) { + call.respond(HttpStatusCode.BadRequest, "Currency code not provided.") + return "Currency code not provided." + } + val currencyValid = uma.getSupportedCurrencies().any { it.code == currencyCode } + if (!currencyValid) { + call.respond(HttpStatusCode.BadRequest, "Currency code not supported.") + return "Currency code not supported." + } + + val vasp2PubKeys = try { + uma.fetchPublicKeysForVasp(initialRequestData.vasp2Domain) + } catch (e: Exception) { + call.respond(HttpStatusCode.FailedDependency, "Failed to fetch public keys.") + return "Failed to fetch public keys." + } + + return "OK" + } + + suspend fun handleClientSendPayment(call: ApplicationCall): String { + return "OK" + } +} diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt b/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt index b048b0c5..80cd0dfe 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt @@ -1,13 +1,15 @@ package com.lightspark.plugins import com.lightspark.UmaConfig +import com.lightspark.Vasp1 import com.lightspark.Vasp2 +import com.lightspark.sdk.ClientConfig +import com.lightspark.sdk.LightsparkCoroutinesClient import com.lightspark.sdk.uma.InMemoryPublicKeyCache import com.lightspark.sdk.uma.UmaProtocolHelper import io.ktor.server.application.Application import io.ktor.server.application.ApplicationCall import io.ktor.server.application.call -import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.routing @@ -15,19 +17,24 @@ import io.ktor.server.routing.routing fun Application.configureRouting(config: UmaConfig) { val pubKeyCache = InMemoryPublicKeyCache() val uma = UmaProtocolHelper(pubKeyCache) + val client = LightsparkCoroutinesClient( + ClientConfig(serverUrl = config.clientBaseURL ?: "api.lightspark.com"), + ) + val vasp1 = Vasp1(config, uma, client) val vasp2 = Vasp2(config, uma) routing { + // VASP1 Routes: get("/api/umalookup/:receiver") { - call.respondText("Hello World!") + call.debugLog(vasp1.handleClientUmaLookup(call)) } get("/api/umapayreq/:callbackUuid") { - call.respondText("Hello World!") + call.debugLog(vasp1.handleClientUmaPayReq(call)) } post("/api/sendpayment/:callbackUuid") { - call.respondText("Hello World!") + call.debugLog(vasp1.handleClientSendPayment(call)) } // End VASP1 Routes From dfc4bc8a47c1a56138afc670bdb4be0749366710 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Tue, 22 Aug 2023 11:32:20 -0700 Subject: [PATCH 2/5] Finished the payreq handler for vasp1 --- .../src/main/kotlin/com/lightspark/Vasp1.kt | 98 ++++++++++++++++++- .../com/lightspark/Vasp1RequestCache.kt | 67 +++++++++++++ 2 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 umaserverdemo/src/main/kotlin/com/lightspark/Vasp1RequestCache.kt diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt index df24b0a3..bbcbe2e9 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt @@ -2,11 +2,17 @@ package com.lightspark import com.lightspark.sdk.LightsparkCoroutinesClient import com.lightspark.sdk.uma.LnurlpResponse +import com.lightspark.sdk.uma.PayReqResponse +import com.lightspark.sdk.uma.PayerDataOptions import com.lightspark.sdk.uma.UmaProtocolHelper import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType import io.ktor.server.application.ApplicationCall import io.ktor.server.response.respond @@ -16,6 +22,7 @@ class Vasp1( private val lightsparkClient: LightsparkCoroutinesClient, ) { private val httpClient = HttpClient() + private val requestDataCache = Vasp1RequestCache() suspend fun handleClientUmaLookup(call: ApplicationCall): String { val receiverAddress = call.parameters["receiver"] @@ -73,14 +80,14 @@ class Vasp1( return "Failed to verify lnurlp response signature." } - // TODO(Jeremy): Save the request info to cache. + val callbackUuid = requestDataCache.saveLnurlpResponseData(lnurlpResponse, receiverId, receiverVasp) call.respond( mapOf( "currencies" to lnurlpResponse.currencies, "minSendSats" to lnurlpResponse.minSendable, "maxSendSats" to lnurlpResponse.maxSendable, - "callbackUuid" to "TODO", + "callbackUuid" to callbackUuid, // You might not actually send this to a client in practice. "isReceiverKYCd" to lnurlpResponse.compliance.isKYCd, ), @@ -95,6 +102,10 @@ class Vasp1( call.respond(HttpStatusCode.BadRequest, "Callback UUID not provided.") return "Callback UUID not provided." } + val initialRequestData = requestDataCache.getLnurlpResponseData(callbackUuid) ?: run { + call.respond(HttpStatusCode.BadRequest, "Callback UUID not found.") + return "Callback UUID not found." + } val amount = call.request.queryParameters["amount"]?.let { it.toLongOrNull() } if (amount == null || amount <= 0) { @@ -107,7 +118,7 @@ class Vasp1( call.respond(HttpStatusCode.BadRequest, "Currency code not provided.") return "Currency code not provided." } - val currencyValid = uma.getSupportedCurrencies().any { it.code == currencyCode } + val currencyValid = initialRequestData.lnurlpResponse.currencies.any { it.code == currencyCode } if (!currencyValid) { call.respond(HttpStatusCode.BadRequest, "Currency code not supported.") return "Currency code not supported." @@ -120,10 +131,91 @@ class Vasp1( return "Failed to fetch public keys." } + val payer = getPayerProfile(initialRequestData.lnurlpResponse.requiredPayerData) + val trInfo = "Here is some fake travel rule info. It's up to you to actually implement this if needed." + val payerUtxos = emptyList() + val utxoCallback = "/api/lnurl/utxocallback?txid=1234" + + val payReq = try { + uma.getPayRequest( + receiverEncryptionPubKey = vasp2PubKeys.encryptionPubKey, + sendingVaspPrivateKey = config.umaSigningPrivKey, + currencyCode = currencyCode, + amount = amount, + payerIdentifier = payer.identifier, + isPayerKYCd = true, + utxoCallback = utxoCallback, + payerUtxos = payerUtxos, + payerName = payer.name, + payerEmail = payer.email, + ) + } catch (e: Exception) { + call.respond(HttpStatusCode.InternalServerError, "Failed to generate payreq.") + return "Failed to generate payreq." + } + + val response = httpClient.post(initialRequestData.lnurlpResponse.callback) { + contentType(ContentType.Application.Json) + setBody(payReq) + } + + if (response.status != HttpStatusCode.OK) { + call.respond(HttpStatusCode.InternalServerError, "Payreq to vasp2 failed: ${response.status}") + return "Payreq to vasp2 failed: ${response.status}" + } + + val payReqResponse = try { + response.body() + } catch (e: Exception) { + call.respond(HttpStatusCode.InternalServerError, "Failed to parse payreq response.") + return "Failed to parse payreq response." + } + + // TODO(Yun): Pre-screen the UTXOs from payreqResponse.compliance.ytxos + + val invoice = try { + lightsparkClient.decodeInvoice(payReqResponse.encodedInvoice) + } catch (e: Exception) { + call.respond(HttpStatusCode.InternalServerError, "Failed to decode invoice.") + return "Failed to decode invoice." + } + + val newCallbackId = requestDataCache.savePayReqData( + encodedInvoice = payReqResponse.encodedInvoice, + utxoCallback = utxoCallback, + invoiceData = invoice, + ) + + call.respond( + mapOf( + "encodedInvoice" to payReqResponse.encodedInvoice, + "callbackUuid" to newCallbackId, + "amount" to invoice.amount, + "conversionRate" to payReqResponse.paymentInfo.multiplier, + "currencyCode" to payReqResponse.paymentInfo.currencyCode, + ), + ) + return "OK" } + /** + * NOTE: In a real application, you'd want to use the authentication context to pull out this information. It's not + * actually always Alice sending the money ;-). + */ + private fun getPayerProfile(requiredPayerData: PayerDataOptions) = PayerProfile( + name = if (requiredPayerData.nameRequired) "Alice FakeName" else null, + email = if (requiredPayerData.emailRequired) "alice@vasp1.com" else null, + identifier = "alice", + ) + suspend fun handleClientSendPayment(call: ApplicationCall): String { return "OK" } } + +private data class PayerProfile( + val name: String?, + val email: String?, + val identifier: String, +) diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1RequestCache.kt b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1RequestCache.kt new file mode 100644 index 00000000..7e1f0e78 --- /dev/null +++ b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1RequestCache.kt @@ -0,0 +1,67 @@ +package com.lightspark + +import com.lightspark.sdk.model.InvoiceData +import com.lightspark.sdk.uma.LnurlpResponse +import java.util.UUID + +/** + * A simple in-memory cache for data that needs to be remembered between calls to VASP1. In practice, this would be + * stored in a database or other persistent storage. + */ +class Vasp1RequestCache { + /** + * This is a map of the UMA request UUID to the LnurlpResponse from that initial Lnurlp request. + * This is used to cache the LnurlpResponse so that we can use it to generate the UMA payreq without the client + * having to make another Lnurlp request or remember lots of details. + * NOTE: In production, this should be stored in a database or other persistent storage. + */ + private val lnurlpRequestCache: MutableMap = mutableMapOf() + + /** + * This is a map of the UMA request UUID to the payreq data that we generated for that request. + * This is used to cache the payreq data so that we can pay the invoice when the user confirms + * NOTE: In production, this should be stored in a database or other persistent storage. + */ + private val payReqCache: MutableMap = mutableMapOf() + + fun getLnurlpResponseData(uuid: String): Vasp1InitialRequestData? { + return lnurlpRequestCache[uuid] + } + + fun getPayReqData(uuid: String): Vasp1PayReqData? { + return payReqCache[uuid] + } + + fun saveLnurlpResponseData(lnurlpResponse: LnurlpResponse, receiverId: String, vasp2Domain: String): String { + val uuid = UUID.randomUUID().toString() + lnurlpRequestCache[uuid] = Vasp1InitialRequestData(lnurlpResponse, receiverId, vasp2Domain) + return uuid + } + + fun savePayReqData(encodedInvoice: String, utxoCallback: String, invoiceData: InvoiceData): String { + val uuid = UUID.randomUUID().toString() + payReqCache[uuid] = Vasp1PayReqData(encodedInvoice, utxoCallback, invoiceData) + return uuid + } + + fun removeLnurlpResponseData(uuid: String) { + lnurlpRequestCache.remove(uuid) + } + + fun removePayReqData(uuid: String) { + payReqCache.remove(uuid) + } +} + +data class Vasp1InitialRequestData( + val lnurlpResponse: LnurlpResponse, + val receiverId: String, + val vasp2Domain: String, +) + +data class Vasp1PayReqData( + val encodedInvoice: String, + val utxoCallback: String, + val invoiceData: InvoiceData, +) + From 51d2bd72e7a3543c2eea5056d87ec1ba695b69b2 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Tue, 22 Aug 2023 18:00:02 -0700 Subject: [PATCH 3/5] Vasp1 almost working --- .../com/lightspark/sdk/uma/LnurlpRequest.kt | 8 +- .../com/lightspark/sdk/uma/LnurlpResponse.kt | 33 ++---- .../lightspark/sdk/uma/UmaProtocolHelper.kt | 3 +- umaserverdemo/build.gradle.kts | 1 + .../src/main/kotlin/com/lightspark/Logging.kt | 7 ++ .../kotlin/com/lightspark/PubKeyHandler.kt | 19 +++ .../src/main/kotlin/com/lightspark/Vasp1.kt | 108 ++++++++++++++++-- .../src/main/kotlin/com/lightspark/Vasp2.kt | 36 +++--- .../kotlin/com/lightspark/plugins/Routing.kt | 45 ++------ 9 files changed, 170 insertions(+), 90 deletions(-) create mode 100644 umaserverdemo/src/main/kotlin/com/lightspark/Logging.kt create mode 100644 umaserverdemo/src/main/kotlin/com/lightspark/PubKeyHandler.kt diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/uma/LnurlpRequest.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/uma/LnurlpRequest.kt index e2e0523d..fa172b8d 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/uma/LnurlpRequest.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/uma/LnurlpRequest.kt @@ -54,13 +54,13 @@ data class LnurlpRequest( if (urlBuilder.protocol != URLProtocol.HTTP && urlBuilder.protocol != URLProtocol.HTTPS) { throw IllegalArgumentException("Invalid URL schema: $url") } - if (urlBuilder.pathSegments.size != 3 - || urlBuilder.pathSegments[0] != ".well-known" - || urlBuilder.pathSegments[1] != "lnurlp" + if (urlBuilder.pathSegments.size != 4 + || urlBuilder.pathSegments[1] != ".well-known" + || urlBuilder.pathSegments[2] != "lnurlp" ) { throw IllegalArgumentException("Invalid uma request path: $url") } - val receiverAddress = "${urlBuilder.host}@${urlBuilder.pathSegments[2]}" + val receiverAddress = "${urlBuilder.host}@${urlBuilder.pathSegments[3]}" val vaspDomain = urlBuilder.parameters["vaspDomain"] val nonce = urlBuilder.parameters["nonce"] val signature = urlBuilder.parameters["signature"] diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/uma/LnurlpResponse.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/uma/LnurlpResponse.kt index dd3b1196..fbcd38d1 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/uma/LnurlpResponse.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/uma/LnurlpResponse.kt @@ -5,7 +5,6 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.encoding.CompositeDecoder import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json @@ -47,7 +46,7 @@ data class PayerDataOptions( // Custom serializer for PayerDataOptions class PayerDataOptionsSerializer : KSerializer { - override val descriptor = PrimitiveSerialDescriptor("PayerDataOptions", PrimitiveKind.STRING) + override val descriptor = PrimitiveSerialDescriptor("payerData", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: PayerDataOptions) { val jsonOutput = """{ @@ -60,28 +59,14 @@ class PayerDataOptionsSerializer : KSerializer { } override fun deserialize(decoder: Decoder): PayerDataOptions { - val compositeInput = decoder.beginStructure(descriptor) - var nameRequired = false - var emailRequired = false - var complianceRequired = false - loop@ while (true) { - when (val index = compositeInput.decodeElementIndex(descriptor)) { - CompositeDecoder.Companion.DECODE_DONE -> break@loop - 0 -> { - val jsonInput = compositeInput.decodeStringElement(descriptor, index) - val json = Json.parseToJsonElement(jsonInput) - val name = json.jsonObject["name"]?.jsonObject - val email = json.jsonObject["email"]?.jsonObject - val compliance = json.jsonObject["compliance"]?.jsonObject - nameRequired = name?.get("mandatory")?.jsonPrimitive?.boolean ?: false - emailRequired = email?.get("mandatory")?.jsonPrimitive?.boolean ?: false - complianceRequired = compliance?.get("mandatory")?.jsonPrimitive?.boolean ?: false - } - - else -> throw IllegalArgumentException("Invalid index $index") - } - } - compositeInput.endStructure(descriptor) + val jsonInput = decoder.decodeString() + val json = Json.parseToJsonElement(jsonInput) + val name = json.jsonObject["name"]?.jsonObject + val email = json.jsonObject["email"]?.jsonObject + val compliance = json.jsonObject["compliance"]?.jsonObject + val nameRequired = name?.get("mandatory")?.jsonPrimitive?.boolean ?: false + val emailRequired = email?.get("mandatory")?.jsonPrimitive?.boolean ?: false + val complianceRequired = compliance?.get("mandatory")?.jsonPrimitive?.boolean ?: false return PayerDataOptions(nameRequired, emailRequired, complianceRequired) } } diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/uma/UmaProtocolHelper.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/uma/UmaProtocolHelper.kt index e1a0b324..95ee8fd6 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/uma/UmaProtocolHelper.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/uma/UmaProtocolHelper.kt @@ -28,7 +28,8 @@ class UmaProtocolHelper( return cached } - val response = umaRequester.makeGetRequest("https://$vaspDomain/.well-known/uma-public-key") + val scheme = if (vaspDomain.startsWith("localhost:")) "http" else "https" + val response = umaRequester.makeGetRequest("$scheme://$vaspDomain/.well-known/lnurlpubkey") val pubKeyResponse = serializerFormat.decodeFromString(response) publicKeyCache.addPublicKeysForVasp(vaspDomain, pubKeyResponse) return pubKeyResponse diff --git a/umaserverdemo/build.gradle.kts b/umaserverdemo/build.gradle.kts index 42d521c1..cbafadd6 100644 --- a/umaserverdemo/build.gradle.kts +++ b/umaserverdemo/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { implementation("io.ktor:ktor-server-default-headers-jvm") implementation("io.ktor:ktor-server-call-logging-jvm") implementation("io.ktor:ktor-server-content-negotiation-jvm") + implementation("io.ktor:ktor-client-content-negotiation-jvm") implementation("io.ktor:ktor-serialization-kotlinx-json-jvm") implementation("io.ktor:ktor-server-auth-jvm") implementation("io.ktor:ktor-server-compression-jvm") diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Logging.kt b/umaserverdemo/src/main/kotlin/com/lightspark/Logging.kt new file mode 100644 index 00000000..2de840dc --- /dev/null +++ b/umaserverdemo/src/main/kotlin/com/lightspark/Logging.kt @@ -0,0 +1,7 @@ +package com.lightspark + +import io.ktor.server.application.ApplicationCall + +fun ApplicationCall.debugLog(message: String) { + application.environment.log.debug(message) +} diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/PubKeyHandler.kt b/umaserverdemo/src/main/kotlin/com/lightspark/PubKeyHandler.kt new file mode 100644 index 00000000..d4aac915 --- /dev/null +++ b/umaserverdemo/src/main/kotlin/com/lightspark/PubKeyHandler.kt @@ -0,0 +1,19 @@ +package com.lightspark + +import com.lightspark.sdk.uma.PubKeyResponse +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.respond + +suspend fun handlePubKeyRequest(call: ApplicationCall, config: UmaConfig): String { + val twoWeeksFromNowMs = System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 14 + + val response = PubKeyResponse( + signingPubKey = config.umaSigningPubKey, + encryptionPubKey = config.umaEncryptionPubKey, + expirationTimestamp = twoWeeksFromNowMs / 1000, + ) + + call.respond(response) + + return "OK" +} diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt index bbcbe2e9..f33966fa 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt @@ -1,27 +1,47 @@ package com.lightspark import com.lightspark.sdk.LightsparkCoroutinesClient +import com.lightspark.sdk.execute +import com.lightspark.sdk.model.OutgoingPayment +import com.lightspark.sdk.model.TransactionStatus import com.lightspark.sdk.uma.LnurlpResponse import com.lightspark.sdk.uma.PayReqResponse import com.lightspark.sdk.uma.PayerDataOptions import com.lightspark.sdk.uma.UmaProtocolHelper 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.post import io.ktor.client.request.setBody 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.response.respond +import io.ktor.server.routing.Routing +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import kotlinx.coroutines.delay +import kotlinx.datetime.Clock +import kotlinx.serialization.json.Json class Vasp1( private val config: UmaConfig, private val uma: UmaProtocolHelper, private val lightsparkClient: LightsparkCoroutinesClient, ) { - private val httpClient = HttpClient() + private val httpClient = HttpClient { + install(ContentNegotiation) { + json( + Json { + isLenient = true + }, + ) + } + } private val requestDataCache = Vasp1RequestCache() suspend fun handleClientUmaLookup(call: ApplicationCall): String { @@ -48,20 +68,22 @@ class Vasp1( trStatus = true, ) - val response = httpClient.get(lnurlpRequest) - if (response == null) { + val response = try { + httpClient.get(lnurlpRequest) + } catch (e: Exception) { call.respond(HttpStatusCode.FailedDependency, "Failed to fetch lnurlp response.") return "Failed to fetch lnurlp response." } if (response.status != HttpStatusCode.OK) { - call.respond(HttpStatusCode.FailedDependency, "Failed to fetch lnurlp response.") + call.respond(HttpStatusCode.FailedDependency, "Failed to fetch lnurlp response. Status: ${response.status}") return "Failed to fetch lnurlp response." } val lnurlpResponse = try { response.body() } catch (e: Exception) { + call.application.environment.log.error("Failed to parse lnurlp response", e) call.respond(HttpStatusCode.FailedDependency, "Failed to parse lnurlp response.") return "Failed to parse lnurlp response." } @@ -69,6 +91,7 @@ class Vasp1( val vasp2PubKey = try { uma.fetchPublicKeysForVasp(receiverVasp) } catch (e: Exception) { + call.application.environment.log.error("Failed to fetch pubkeys", e) call.respond(HttpStatusCode.FailedDependency, "Failed to fetch public keys.") return "Failed to fetch public keys." } @@ -97,8 +120,7 @@ class Vasp1( } suspend fun handleClientUmaPayReq(call: ApplicationCall): String { - val callbackUuid = call.parameters["callbackUuid"] - if (callbackUuid == null) { + val callbackUuid = call.parameters["callbackUuid"] ?: run { call.respond(HttpStatusCode.BadRequest, "Callback UUID not provided.") return "Callback UUID not provided." } @@ -107,7 +129,7 @@ class Vasp1( return "Callback UUID not found." } - val amount = call.request.queryParameters["amount"]?.let { it.toLongOrNull() } + val amount = call.request.queryParameters["amount"]?.toLongOrNull() if (amount == null || amount <= 0) { call.respond(HttpStatusCode.BadRequest, "Amount invalid or not provided.") return "Amount invalid or not provided." @@ -127,6 +149,7 @@ class Vasp1( val vasp2PubKeys = try { uma.fetchPublicKeysForVasp(initialRequestData.vasp2Domain) } catch (e: Exception) { + call.application.environment.log.error("Failed to fetch pubkeys", e) call.respond(HttpStatusCode.FailedDependency, "Failed to fetch public keys.") return "Failed to fetch public keys." } @@ -145,11 +168,13 @@ class Vasp1( payerIdentifier = payer.identifier, isPayerKYCd = true, utxoCallback = utxoCallback, + trInfo = trInfo, payerUtxos = payerUtxos, payerName = payer.name, payerEmail = payer.email, ) } catch (e: Exception) { + call.application.environment.log.error("Failed to generate payreq", e) call.respond(HttpStatusCode.InternalServerError, "Failed to generate payreq.") return "Failed to generate payreq." } @@ -176,6 +201,7 @@ class Vasp1( val invoice = try { lightsparkClient.decodeInvoice(payReqResponse.encodedInvoice) } catch (e: Exception) { + call.application.environment.log.error("Failed to decode invoice", e) call.respond(HttpStatusCode.InternalServerError, "Failed to decode invoice.") return "Failed to decode invoice." } @@ -210,8 +236,76 @@ class Vasp1( ) suspend fun handleClientSendPayment(call: ApplicationCall): String { + val callbackUuid = call.parameters["callbackUuid"] ?: run { + call.respond(HttpStatusCode.BadRequest, "Callback UUID not provided.") + return "Callback UUID not provided." + } + val payReqData = requestDataCache.getPayReqData(callbackUuid) ?: run { + call.respond(HttpStatusCode.BadRequest, "Callback UUID not found.") + return "Callback UUID not found." + } + + if (payReqData.invoiceData.expiresAt < Clock.System.now()) { + call.respond(HttpStatusCode.BadRequest, "Invoice expired.") + return "Invoice expired." + } + + if (payReqData.invoiceData.amount.originalValue <= 0) { + call.respond(HttpStatusCode.BadRequest, "Invoice amount invalid. Uma requires positive amounts.") + return "Invoice amount invalid." + } + + val payment = try { + val pendingPayment = lightsparkClient.payInvoice( + config.nodeID, + payReqData.encodedInvoice, + maxFeesMsats = 1_000_000L, + ) + waitForPaymentCompletion(pendingPayment) + } catch (e: Exception) { + call.application.environment.log.error("Failed to pay invoice", e) + call.respond(HttpStatusCode.InternalServerError, "Failed to pay invoice.") + return "Failed to pay invoice." + } + + call.respond( + mapOf( + "didSucceed" to (payment.status == TransactionStatus.SUCCESS), + "paymentId" to payment.id, + ), + ) + return "OK" } + + // TODO(Jeremy): Expose payInvoiceAndAwaitCompletion in the lightspark-sdk instead. + private suspend fun waitForPaymentCompletion(pendingPayment: OutgoingPayment): OutgoingPayment { + var attemptsLeft = 40 + var payment = pendingPayment + while (payment.status == TransactionStatus.PENDING && attemptsLeft-- > 0) { + delay(250) + payment = OutgoingPayment.getOutgoingPaymentQuery(payment.id).execute(lightsparkClient) + ?: throw Exception("Payment not found.") + } + if (payment.status == TransactionStatus.PENDING) { + throw Exception("Payment timed out.") + } + return payment + } +} + +fun Routing.registerVasp1Routes(vasp1: Vasp1) { + get("/api/umalookup/{receiver}") { + call.debugLog(vasp1.handleClientUmaLookup(call)) + } + + get("/api/umapayreq/{callbackUuid}") { + call.debugLog(vasp1.handleClientUmaPayReq(call)) + } + + post("/api/sendpayment/{callbackUuid}") { + call.debugLog(vasp1.handleClientSendPayment(call)) + } } private data class PayerProfile( diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt index 39ea22a1..1205960a 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt @@ -7,16 +7,19 @@ import com.lightspark.sdk.uma.Currency import com.lightspark.sdk.uma.LnurlInvoiceCreator import com.lightspark.sdk.uma.PayRequest import com.lightspark.sdk.uma.PayerDataOptions -import com.lightspark.sdk.uma.PubKeyResponse import com.lightspark.sdk.uma.UmaProtocolHelper import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.call import io.ktor.server.plugins.origin import io.ktor.server.request.ApplicationRequest import io.ktor.server.request.host import io.ktor.server.request.receive import io.ktor.server.request.uri import io.ktor.server.response.respond +import io.ktor.server.routing.Routing +import io.ktor.server.routing.get +import io.ktor.server.routing.post import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -41,7 +44,8 @@ class Vasp2( if (uma.isUmaLnurlpQuery(requestUrl)) { return handleUmaLnurlp(call) } - return "Hello World!" + + return "OK" } private suspend fun handleUmaLnurlp(call: ApplicationCall): String { @@ -190,20 +194,6 @@ class Vasp2( return "OK" } - suspend fun handlePubKeyRequest(call: ApplicationCall): String { - val twoWeeksFromNowMs = System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 14 - - val response = PubKeyResponse( - signingPubKey = config.umaSigningPubKey, - encryptionPubKey = config.umaEncryptionPubKey, - expirationTimestamp = twoWeeksFromNowMs / 1000, - ) - - call.respond(response) - - return "OK" - } - private fun getEncodedMetadata(): String { val metadata = mapOf( "text/plain" to "Pay to ${config.username}@vasp2.com", @@ -233,3 +223,17 @@ class Vasp2( return "$protocol://$host$path" } } + +fun Routing.registerVasp2Routes(vasp2: Vasp2) { + get("/.well-known/lnurlp/{username}") { + call.debugLog(vasp2.handleLnurlp(call)) + } + + get("/api/uma/payreq/{uuid}") { + call.debugLog(vasp2.handleLnurlPayreq(call)) + } + + post("/api/uma/payreq/{uuid}") { + call.debugLog(vasp2.handleUmaPayreq(call)) + } +} diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt b/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt index 80cd0dfe..b29f9b75 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt @@ -3,15 +3,17 @@ package com.lightspark.plugins import com.lightspark.UmaConfig import com.lightspark.Vasp1 import com.lightspark.Vasp2 +import com.lightspark.debugLog +import com.lightspark.handlePubKeyRequest +import com.lightspark.registerVasp1Routes +import com.lightspark.registerVasp2Routes import com.lightspark.sdk.ClientConfig import com.lightspark.sdk.LightsparkCoroutinesClient import com.lightspark.sdk.uma.InMemoryPublicKeyCache import com.lightspark.sdk.uma.UmaProtocolHelper import io.ktor.server.application.Application -import io.ktor.server.application.ApplicationCall import io.ktor.server.application.call import io.ktor.server.routing.get -import io.ktor.server.routing.post import io.ktor.server.routing.routing fun Application.configureRouting(config: UmaConfig) { @@ -24,44 +26,11 @@ fun Application.configureRouting(config: UmaConfig) { val vasp2 = Vasp2(config, uma) routing { - // VASP1 Routes: - get("/api/umalookup/:receiver") { - call.debugLog(vasp1.handleClientUmaLookup(call)) - } - - get("/api/umapayreq/:callbackUuid") { - call.debugLog(vasp1.handleClientUmaPayReq(call)) - } - - post("/api/sendpayment/:callbackUuid") { - call.debugLog(vasp1.handleClientSendPayment(call)) - } - - // End VASP1 Routes - - // VASP2 Routes: - get("/.well-known/lnurlp/:username") { - call.debugLog(vasp2.handleLnurlp(call)) - } - - get("/api/uma/payreq/:uuid") { - call.debugLog(vasp2.handleLnurlPayreq(call)) - } - - post("/api/uma/payreq/:uuid") { - call.debugLog(vasp2.handleUmaPayreq(call)) - } - // End VASP2 Routes - - // Shared: + registerVasp1Routes(vasp1) + registerVasp2Routes(vasp2) get("/.well-known/lnurlpubkey") { - // It doesn't matter which vasp protocol handles this since they share a config and cache. - call.debugLog(vasp2.handlePubKeyRequest(call)) + call.debugLog(handlePubKeyRequest(call, config)) } } } - -private fun ApplicationCall.debugLog(message: String) { - application.environment.log.debug(message) -} From 611a6a22a6e1b70b85cd0ec5edabe4fe9a521dc3 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Wed, 23 Aug 2023 14:03:01 -0700 Subject: [PATCH 4/5] Some more fixes --- .../src/main/kotlin/com/lightspark/Vasp1.kt | 43 +++++++++++-------- .../src/main/kotlin/com/lightspark/Vasp2.kt | 16 +++++-- .../kotlin/com/lightspark/plugins/Routing.kt | 6 ++- 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt index f33966fa..ba633a59 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt @@ -27,6 +27,11 @@ import io.ktor.server.routing.post import kotlinx.coroutines.delay import kotlinx.datetime.Clock import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray class Vasp1( private val config: UmaConfig, @@ -106,14 +111,16 @@ class Vasp1( val callbackUuid = requestDataCache.saveLnurlpResponseData(lnurlpResponse, receiverId, receiverVasp) call.respond( - mapOf( - "currencies" to lnurlpResponse.currencies, - "minSendSats" to lnurlpResponse.minSendable, - "maxSendSats" to lnurlpResponse.maxSendable, - "callbackUuid" to callbackUuid, + buildJsonObject { + putJsonArray("currencies") { + addAll(lnurlpResponse.currencies.map { Json.encodeToJsonElement(it) }) + } + put("minSendSats", lnurlpResponse.minSendable) + put("maxSendSats", lnurlpResponse.maxSendable) + put("callbackUuid", callbackUuid) // You might not actually send this to a client in practice. - "isReceiverKYCd" to lnurlpResponse.compliance.isKYCd, - ), + put("isReceiverKYCd", lnurlpResponse.compliance.isKYCd) + } ) return "OK" @@ -213,13 +220,13 @@ class Vasp1( ) call.respond( - mapOf( - "encodedInvoice" to payReqResponse.encodedInvoice, - "callbackUuid" to newCallbackId, - "amount" to invoice.amount, - "conversionRate" to payReqResponse.paymentInfo.multiplier, - "currencyCode" to payReqResponse.paymentInfo.currencyCode, - ), + buildJsonObject { + put("encodedInvoice", payReqResponse.encodedInvoice) + put("callbackUuid", newCallbackId) + put("amount", Json.encodeToJsonElement(invoice.amount)) + put("conversionRate", payReqResponse.paymentInfo.multiplier) + put("currencyCode", payReqResponse.paymentInfo.currencyCode) + }, ) return "OK" @@ -269,10 +276,10 @@ class Vasp1( } call.respond( - mapOf( - "didSucceed" to (payment.status == TransactionStatus.SUCCESS), - "paymentId" to payment.id, - ), + buildJsonObject { + put("didSucceed", (payment.status == TransactionStatus.SUCCESS)) + put("paymentId", payment.id) + }, ) return "OK" diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt index 1205960a..de673177 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt @@ -2,6 +2,7 @@ package com.lightspark import com.lightspark.sdk.ClientConfig import com.lightspark.sdk.LightsparkCoroutinesClient +import com.lightspark.sdk.auth.AccountApiTokenAuthProvider import com.lightspark.sdk.model.Invoice import com.lightspark.sdk.uma.Currency import com.lightspark.sdk.uma.LnurlInvoiceCreator @@ -124,7 +125,10 @@ class Vasp2( } val client = LightsparkCoroutinesClient( - ClientConfig(serverUrl = config.clientBaseURL ?: "api.lightspark.com"), + ClientConfig( + serverUrl = config.clientBaseURL ?: "api.lightspark.com", + authProvider = AccountApiTokenAuthProvider(config.apiClientID, config.apiClientSecret) + ), ) val invoice = try { client.createLnurlInvoice(config.nodeID, amountMsats, getEncodedMetadata()) @@ -165,7 +169,10 @@ class Vasp2( val conversionRate = 34_150L // In real life, this would come from some actual exchange rate API. val client = LightsparkCoroutinesClient( - ClientConfig(serverUrl = config.clientBaseURL ?: "api.lightspark.com"), + ClientConfig( + serverUrl = config.clientBaseURL ?: "api.lightspark.com", + authProvider = AccountApiTokenAuthProvider(config.apiClientID, config.apiClientSecret) + ), ) val response = try { @@ -185,6 +192,7 @@ class Vasp2( utxoCallback = getUtxoCallback(call, "1234"), ) } catch (e: Exception) { + call.application.environment.log.error("Failed to create payreq response.", e) call.respond(HttpStatusCode.InternalServerError, "Failed to create payreq response.") return "Failed to create payreq response." } @@ -204,9 +212,11 @@ class Vasp2( private fun getLnurlpCallback(call: ApplicationCall): String { val protocol = call.request.origin.scheme + val port = call.request.origin.localPort + val portString = if (port == 80 || port == 443) "" else ":$port" val host = call.request.host() val path = "/api/uma/payreq/${config.userID}" - return "$protocol://$host$path" + return "$protocol://$host$portString$path" } private fun getUtxoCallback(call: ApplicationCall, txId: String): String { diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt b/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt index b29f9b75..56c296cf 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt @@ -9,6 +9,7 @@ import com.lightspark.registerVasp1Routes import com.lightspark.registerVasp2Routes import com.lightspark.sdk.ClientConfig import com.lightspark.sdk.LightsparkCoroutinesClient +import com.lightspark.sdk.auth.AccountApiTokenAuthProvider import com.lightspark.sdk.uma.InMemoryPublicKeyCache import com.lightspark.sdk.uma.UmaProtocolHelper import io.ktor.server.application.Application @@ -20,7 +21,10 @@ fun Application.configureRouting(config: UmaConfig) { val pubKeyCache = InMemoryPublicKeyCache() val uma = UmaProtocolHelper(pubKeyCache) val client = LightsparkCoroutinesClient( - ClientConfig(serverUrl = config.clientBaseURL ?: "api.lightspark.com"), + ClientConfig( + serverUrl = config.clientBaseURL ?: "api.lightspark.com", + authProvider = AccountApiTokenAuthProvider(config.apiClientID, config.apiClientSecret) + ), ) val vasp1 = Vasp1(config, uma, client) val vasp2 = Vasp2(config, uma) From a2e13deb63cdb53c35c822513de2b9fb0a06a23d Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Fri, 8 Sep 2023 00:43:17 -0700 Subject: [PATCH 5/5] Fixes after a rebase --- .../src/main/kotlin/com/lightspark/Vasp1.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt index ba633a59..a832b86f 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt @@ -20,6 +20,8 @@ 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 +import io.ktor.server.request.host import io.ktor.server.response.respond import io.ktor.server.routing.Routing import io.ktor.server.routing.get @@ -70,7 +72,7 @@ class Vasp1( receiverAddress = receiverAddress, // TODO: This should be configurable. senderVaspDomain = "localhost:8080", - trStatus = true, + isSubjectToTravelRule = true, ) val response = try { @@ -164,7 +166,6 @@ class Vasp1( val payer = getPayerProfile(initialRequestData.lnurlpResponse.requiredPayerData) val trInfo = "Here is some fake travel rule info. It's up to you to actually implement this if needed." val payerUtxos = emptyList() - val utxoCallback = "/api/lnurl/utxocallback?txid=1234" val payReq = try { uma.getPayRequest( @@ -174,8 +175,8 @@ class Vasp1( amount = amount, payerIdentifier = payer.identifier, isPayerKYCd = true, - utxoCallback = utxoCallback, - trInfo = trInfo, + utxoCallback = getUtxoCallback(call, "1234abc"), + travelRuleInfo = trInfo, payerUtxos = payerUtxos, payerName = payer.name, payerEmail = payer.email, @@ -215,7 +216,7 @@ class Vasp1( val newCallbackId = requestDataCache.savePayReqData( encodedInvoice = payReqResponse.encodedInvoice, - utxoCallback = utxoCallback, + utxoCallback = getUtxoCallback(call, "1234abc"), invoiceData = invoice, ) @@ -242,6 +243,13 @@ class Vasp1( identifier = "alice", ) + private fun getUtxoCallback(call: ApplicationCall, txId: String): String { + val protocol = call.request.origin.scheme + val host = call.request.host() + val path = "/api/uma/utxoCallback?txId=${config.userID}" + return "$protocol://$host$path" + } + suspend fun handleClientSendPayment(call: ApplicationCall): String { val callbackUuid = call.parameters["callbackUuid"] ?: run { call.respond(HttpStatusCode.BadRequest, "Callback UUID not provided.")