diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/LightsparkCoroutinesClient.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/LightsparkCoroutinesClient.kt index a51c1201..af567e28 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/LightsparkCoroutinesClient.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/LightsparkCoroutinesClient.kt @@ -250,6 +250,47 @@ class LightsparkCoroutinesClient private constructor( ) } + /** + * Creates a Lightning invoice for the given node. This should only be used for generating invoices for UMA, with + * [LightsparkCoroutinesClient.createInvoice] preferred in the general case. + * + * @param nodeId The ID of the node for which to create the invoice. + * @param amountMsats The amount of the invoice in milli-satoshis. + * @param metadata The LNURL metadata payload field from the initial payreq response. This will be hashed and + * present in the h-tag (SHA256 purpose of payment) of the resulting Bolt 11 invoice. + * @param expirySecs The number of seconds until the invoice expires. Defaults to 1 day. + */ + suspend fun createUmaInvoice( + nodeId: String, + amountMsats: Long, + metadata: String, + expirySecs: Int? = null, + ): Invoice { + requireValidAuth() + + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(metadata.toByteArray()) + val metadataHash = digest.fold(StringBuilder()) { sb, it -> sb.append("%02x".format(it)) }.toString() + + return executeQuery( + Query( + CreateUmaInvoiceMutation, + { + add("nodeId", nodeId) + add("amountMsats", amountMsats) + add("metadataHash", metadataHash) + expirySecs?.let { add("expirySecs", expirySecs) } + }, + ) { + val invoiceJson = + requireNotNull( + it["create_uma_invoice"]?.jsonObject?.get("invoice"), + ) { "No invoice found in response" } + serializerFormat.decodeFromJsonElement(invoiceJson) + }, + ) + } + /** * Pay a lightning invoice for the given node. * @@ -296,6 +337,48 @@ class LightsparkCoroutinesClient private constructor( ) } + /** + * [payUmaInvoice] sends an UMA payment to a node on the Lightning Network, based on the invoice (as defined by the + * BOLT11 specification) that you provide. This should only be used for paying UMA invoices, with [payInvoice] + * preferred in the general case. + * + * @param nodeId The ID of the node which will pay the invoice. + * @param encodedInvoice An encoded string representation of the invoice to pay. + * @param maxFeesMsats The maximum fees to pay in milli-satoshis. You must pass a value. + * As guidance, a maximum fee of 15 basis points should make almost all transactions succeed. For example, + * for a transaction between 10k sats and 100k sats, this would mean a fee limit of 15 to 150 sats. + * @param amountMsats The amount to pay in milli-satoshis. Defaults to the full amount of the invoice. + * @param timeoutSecs The number of seconds to wait for the payment to complete. Defaults to 60. + * @return The payment details. + */ + @JvmOverloads + suspend fun payUmaInvoice( + nodeId: String, + encodedInvoice: String, + maxFeesMsats: Long, + amountMsats: Long? = null, + timeoutSecs: Int = 60, + ): OutgoingPayment { + requireValidAuth() + return executeQuery( + Query( + PayUmaInvoiceMutation, + { + add("node_id", nodeId) + add("encoded_invoice", encodedInvoice) + add("timeout_secs", timeoutSecs) + add("maximum_fees_msats", maxFeesMsats) + amountMsats?.let { add("amount_msats", amountMsats) } + }, + signingNodeId = nodeId, + ) { + val paymentJson = + requireNotNull(it["pay_uma_invoice"]?.jsonObject?.get("payment")) { "No payment found in response" } + serializerFormat.decodeFromJsonElement(paymentJson) + }, + ) + } + /** * Decode a lightning invoice to get its details included payment amount, destination, etc. * diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/CreateUmaInvoice.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/CreateUmaInvoice.kt new file mode 100644 index 00000000..5cc3c751 --- /dev/null +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/CreateUmaInvoice.kt @@ -0,0 +1,20 @@ +package com.lightspark.sdk.graphql + +import com.lightspark.sdk.model.Invoice + +const val CreateUmaInvoiceMutation = """ + mutation CreateUmaInvoice( + ${'$'}nodeId: ID! + ${'$'}amountMsats: Long! + ${'$'}metadataHash: String! + ${'$'}expirySecs: Int = null + ) { + create_uma_invoice(input: { node_id: ${'$'}nodeId, amount_msats: ${'$'}amountMsats, metadata_hash: ${'$'}metadataHash, expiry_secs: ${'$'}expirySecs }) { + invoice { + ...InvoiceFragment + } + } + } + + ${Invoice.FRAGMENT} +""" diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayUmaInvoice.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayUmaInvoice.kt new file mode 100644 index 00000000..48454c7a --- /dev/null +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayUmaInvoice.kt @@ -0,0 +1,29 @@ +package com.lightspark.sdk.graphql + +import com.lightspark.sdk.model.OutgoingPayment + +const val PayUmaInvoiceMutation = """ + mutation PayUmaInvoice( + ${'$'}node_id: ID! + ${'$'}encoded_invoice: String! + ${'$'}timeout_secs: Int! + ${'$'}maximum_fees_msats: Long! + ${'$'}amount_msats: Long + ) { + pay_uma_invoice( + input: { + node_id: ${'$'}node_id + encoded_invoice: ${'$'}encoded_invoice + timeout_secs: ${'$'}timeout_secs + amount_msats: ${'$'}amount_msats + maximum_fees_msats: ${'$'}maximum_fees_msats + } + ) { + payment { + ...OutgoingPaymentFragment + } + } + } + + ${OutgoingPayment.FRAGMENT} +""" 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 78023d8f..5bbf37c2 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 @@ -1,5 +1,6 @@ package com.lightspark.sdk.uma +import com.lightspark.sdk.LightsparkCoroutinesClient import com.lightspark.sdk.model.Invoice import com.lightspark.sdk.util.serializerFormat import java.security.MessageDigest @@ -285,7 +286,7 @@ class UmaProtocolHelper( * Creates an uma pay request response with an encoded invoice. * * @param query The [PayRequest] sent by the sender. - * @param invoiceCreator The [LnurlInvoiceCreator] that will be used to create the invoice. + * @param invoiceCreator The [UmaInvoiceCreator] that will be used to create the invoice. * @param nodeId The node ID of the receiver. * @param metadata The metadata that will be added to the invoice's metadata hash field. * @param currencyCode The code of the currency that the receiver will receive for this payment. @@ -302,7 +303,7 @@ class UmaProtocolHelper( */ suspend fun getPayReqResponse( query: PayRequest, - invoiceCreator: LnurlInvoiceCreator, + invoiceCreator: UmaInvoiceCreator, metadata: String, currencyCode: String, conversionRate: Long, @@ -312,7 +313,7 @@ class UmaProtocolHelper( ): PayReqResponse { val encodedPayerData = serializerFormat.encodeToString(query.payerData) val metadataWithPayerData = "$metadata$encodedPayerData" - val invoice = invoiceCreator.createLnurlInvoice( + val invoice = invoiceCreator.createUmaInvoice( amountMsats = query.amount * conversionRate, metadata = metadataWithPayerData, ) @@ -353,7 +354,16 @@ class UmaProtocolHelper( } } -interface LnurlInvoiceCreator { +interface UmaInvoiceCreator { // TODO: Figure out the async story here. Do we need a different implementation for each client type? - suspend fun createLnurlInvoice(amountMsats: Long, metadata: String): Invoice + suspend fun createUmaInvoice(amountMsats: Long, metadata: String): Invoice +} + +class LightsparkClientUmaInvoiceCreator( + private val client: LightsparkCoroutinesClient, + private val nodeId: String, +) : UmaInvoiceCreator { + override suspend fun createUmaInvoice(amountMsats: Long, metadata: String): Invoice { + return client.createUmaInvoice(nodeId, amountMsats, metadata) + } } diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt index 95566b71..410ee866 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt @@ -6,7 +6,8 @@ import com.lightspark.sdk.auth.AccountApiTokenAuthProvider import com.lightspark.sdk.model.Invoice import com.lightspark.sdk.uma.Currency import com.lightspark.sdk.uma.KycStatus -import com.lightspark.sdk.uma.LnurlInvoiceCreator +import com.lightspark.sdk.uma.LightsparkClientUmaInvoiceCreator +import com.lightspark.sdk.uma.UmaInvoiceCreator import com.lightspark.sdk.uma.PayRequest import com.lightspark.sdk.uma.PayerDataOptions import com.lightspark.sdk.uma.UmaProtocolHelper @@ -193,11 +194,7 @@ class Vasp2( val response = try { uma.getPayReqResponse( query = request, - invoiceCreator = object : LnurlInvoiceCreator { - override suspend fun createLnurlInvoice(amountMsats: Long, metadata: String): Invoice { - return client.createLnurlInvoice(config.nodeID, amountMsats, metadata) - } - }, + invoiceCreator = LightsparkClientUmaInvoiceCreator(client, config.nodeID), metadata = getEncodedMetadata(), currencyCode = "USD", conversionRate = conversionRate,