From 9beefe15b07c35d67a788ccf74f020e8ea999c5c Mon Sep 17 00:00:00 2001 From: Shreya Vissamsetti Date: Thu, 21 Mar 2024 14:33:47 -0700 Subject: [PATCH] Add backwards-compatibility for UMA v0 from v1 SDK (#31) --- .../test/java/me/uma/javatest/UmaTest.java | 183 +++++++++++++++- .../kotlin/me/uma/UmaProtocolHelper.kt | 108 +++++++--- .../kotlin/me/uma/protocol/Currency.kt | 145 +++++++++++-- .../kotlin/me/uma/protocol/LnurlpResponse.kt | 4 +- .../kotlin/me/uma/protocol/PayReqResponse.kt | 121 ++++++++--- .../kotlin/me/uma/protocol/PayRequest.kt | 195 +++++++++++++----- .../kotlin/me/uma/protocol/PubKeyResponse.kt | 8 +- .../kotlin/me/uma/utils/Serialization.kt | 21 ++ .../src/commonTest/kotlin/me/uma/UmaTests.kt | 37 +++- 9 files changed, 680 insertions(+), 142 deletions(-) diff --git a/javatest/src/test/java/me/uma/javatest/UmaTest.java b/javatest/src/test/java/me/uma/javatest/UmaTest.java index b427757..aeb04c0 100644 --- a/javatest/src/test/java/me/uma/javatest/UmaTest.java +++ b/javatest/src/test/java/me/uma/javatest/UmaTest.java @@ -14,6 +14,7 @@ import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.Assertions.*; +import static me.uma.protocol.CurrencyUtils.createCurrency; public class UmaTest { UmaProtocolHelper umaProtocolHelper = new UmaProtocolHelper(new InMemoryPublicKeyCache(), new TestUmaRequester()); @@ -82,7 +83,7 @@ public void testGetLnurlpRequest() throws Exception { } @Test - public void testGetLnurlpResponse() throws Exception { + public void testGetLnurlpResponse_umaV1() throws Exception { String lnurlpUrl = umaProtocolHelper.getSignedLnurlpRequestUrl( privateKeyBytes(), "$bob@vasp2.com", @@ -108,13 +109,15 @@ public void testGetLnurlpResponse() throws Exception { ) ), List.of( - new Currency( + createCurrency( "USD", "US Dollar", "$", 34_150, - new CurrencyConvertible(1, 10_000_000), - 2 + 2, + 1, + 10_000_000, + "1.0" ) ), KycStatus.VERIFIED @@ -125,13 +128,133 @@ public void testGetLnurlpResponse() throws Exception { LnurlpResponse parsedResponse = umaProtocolHelper.parseAsLnurlpResponse(responseJson); assertNotNull(parsedResponse); assertEquals(lnurlpResponse, parsedResponse); + assertNotNull(parsedResponse.asUmaResponse()); + assertEquals(1, parsedResponse.asUmaResponse().getCurrencies().get(0).minSendable()); + assertEquals(10_000_000, parsedResponse.asUmaResponse().getCurrencies().get(0).maxSendable()); assertTrue(umaProtocolHelper.verifyLnurlpResponseSignature( requireNonNull(parsedResponse.asUmaResponse()), new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), new InMemoryNonceCache(1L))); } @Test - public void testGetPayReqResponseSync() throws Exception { + public void testGetLnurlpResponse_umaV0() throws Exception { + String lnurlpUrl = umaProtocolHelper.getSignedLnurlpRequestUrl( + privateKeyBytes(), + "$bob@vasp2.com", + "https://vasp.com", + true); + LnurlpRequest request = umaProtocolHelper.parseLnurlpRequest(lnurlpUrl); + assertNotNull(request); + LnurlpResponse lnurlpResponse = umaProtocolHelper.getLnurlpResponse( + request, + privateKeyBytes(), + true, + "https://vasp2.com/callback", + "encoded metadata", + 1, + 10_000_000, + CounterPartyData.createCounterPartyDataOptions( + Map.of( + "name", false, + "email", false, + "identity", true, + "compliance", true + ) + ), + List.of( + createCurrency( + "USD", + "US Dollar", + "$", + 34_150, + 2, + 1, + 10_000_000, + "0.3" + ) + ), + KycStatus.VERIFIED + ); + assertNotNull(lnurlpResponse); + String responseJson = lnurlpResponse.toJson(); + System.out.println(responseJson); + LnurlpResponse parsedResponse = umaProtocolHelper.parseAsLnurlpResponse(responseJson); + assertNotNull(parsedResponse); + assertEquals(lnurlpResponse, parsedResponse); + assertNotNull(parsedResponse.asUmaResponse()); + assertEquals(1, parsedResponse.asUmaResponse().getCurrencies().get(0).minSendable()); + assertEquals(10_000_000, parsedResponse.asUmaResponse().getCurrencies().get(0).maxSendable()); + assertTrue(umaProtocolHelper.verifyLnurlpResponseSignature( + parsedResponse.asUmaResponse(), new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), + new InMemoryNonceCache(1L))); + } + + @Test + public void testGetPayRequest_umaV1() throws Exception { + PayRequest request = umaProtocolHelper.getPayRequest( + publicKeyBytes(), + privateKeyBytes(), + "USD", + 100L, + true, + "$alice@vasp1.com", + KycStatus.VERIFIED, + "", + null, + null, + null, + "payerName", + "payerEmail", + null, + null, + "comment", + "1.0" + ); + assertNotNull(request); + System.out.println(request); + assertTrue(umaProtocolHelper.verifyPayReqSignature( + request, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), + new InMemoryNonceCache(1L))); + String requestJson = request.toJson(); + PayRequest parsedRequest = umaProtocolHelper.parseAsPayRequest(requestJson); + assertNotNull(parsedRequest); + assertEquals(request, parsedRequest); + } + + @Test + public void testGetPayRequest_umaV0() throws Exception { + PayRequest request = umaProtocolHelper.getPayRequest( + publicKeyBytes(), + privateKeyBytes(), + "USD", + 100L, + true, + "$alice@vasp1.com", + KycStatus.VERIFIED, + "", + null, + null, + null, + "payerName", + "payerEmail", + null, + null, + "comment", + "0.3" + ); + assertNotNull(request); + System.out.println(request); + assertTrue(umaProtocolHelper.verifyPayReqSignature( + request, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), + new InMemoryNonceCache(1L))); + String requestJson = request.toJson(); + PayRequest parsedRequest = umaProtocolHelper.parseAsPayRequest(requestJson); + assertNotNull(parsedRequest); + assertEquals(request, parsedRequest); + } + + @Test + public void testGetPayReqResponseSync_umaV1() throws Exception { PayRequest request = umaProtocolHelper.getPayRequest( publicKeyBytes(), privateKeyBytes(), @@ -154,7 +277,51 @@ public void testGetPayReqResponseSync() throws Exception { null, "", privateKeyBytes(), - PayeeData.createPayeeData(null, "$bob@vasp2.com") + PayeeData.createPayeeData(null, "$bob@vasp2.com"), + null, + null, + "1.0" + ); + assertNotNull(response); + assertEquals("lnbc12345", response.getEncodedInvoice()); + System.out.println(response); + assertTrue(umaProtocolHelper.verifyPayReqResponseSignature( + response, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), + "$alice@vasp1.com", new InMemoryNonceCache(1L))); + String responseJson = response.toJson(); + PayReqResponse parsedResponse = umaProtocolHelper.parseAsPayReqResponse(responseJson); + assertNotNull(parsedResponse); + assertEquals(response, parsedResponse); + } + + @Test + public void testGetPayReqResponseSync_umaV0() throws Exception { + PayRequest request = umaProtocolHelper.getPayRequest( + publicKeyBytes(), + privateKeyBytes(), + "USD", + 100L, + true, + "$alice@vasp1.com", + KycStatus.VERIFIED, + "" + ); + PayReqResponse response = umaProtocolHelper.getPayReqResponseSync( + request, + new TestSyncUmaInvoiceCreator(), + "metadata", + "USD", + 2, + 12345.0, + 0L, + List.of(), + null, + "", + privateKeyBytes(), + PayeeData.createPayeeData(null, "$bob@vasp2.com"), + null, + null, + "0.3" ); assertNotNull(response); assertEquals("lnbc12345", response.getEncodedInvoice()); @@ -162,6 +329,10 @@ public void testGetPayReqResponseSync() throws Exception { assertTrue(umaProtocolHelper.verifyPayReqResponseSignature( response, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), "$alice@vasp1.com", new InMemoryNonceCache(1L))); + String responseJson = response.toJson(); + PayReqResponse parsedResponse = umaProtocolHelper.parseAsPayReqResponse(responseJson); + assertNotNull(parsedResponse); + assertEquals(response, parsedResponse); } @Test diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt index b77c6be..90eccb6 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt @@ -219,12 +219,31 @@ class UmaProtocolHelper @JvmOverloads constructor( val complianceResponse = getSignedLnurlpComplianceResponse(query, privateKeyBytes, requiresTravelRuleInfo, receiverKycStatus) val umaVersion = minOf(Version.parse(umaRequest.umaVersion), Version.parse(UMA_VERSION_STRING)).toString() + val currencies = if (Version.parse(umaVersion).major < 1) { + currencyOptions.map { + if (it is CurrencyV1) { + CurrencyV0( + code = it.code, + name = it.name, + symbol = it.symbol, + millisatoshiPerUnit = it.millisatoshiPerUnit, + minSendable = it.convertible.min, + maxSendable = it.convertible.max, + decimals = it.decimals, + ) + } else { + it + } + } + } else { + currencyOptions + } return LnurlpResponse( callback = callback, minSendable = minSendableSats * 1000, maxSendable = maxSendableSats * 1000, metadata = encodedMetadata, - currencies = currencyOptions, + currencies = currencies, requiredPayerData = payerDataOptions, compliance = complianceResponse, umaVersion = umaVersion, @@ -309,6 +328,7 @@ class UmaProtocolHelper @JvmOverloads constructor( * @param comment A comment that the sender would like to include with the payment. This can only be included * if the receiver included the `commentAllowed` field in the lnurlp response. The length of the comment must be * less than or equal to the value of `commentAllowed`. + * @param receiverUmaVersion The UMA version of the receiver VASP. This information can be obtained from the [LnurlpResponse] * @return The [PayRequest] that should be sent to the receiver. */ @JvmOverloads @@ -329,6 +349,7 @@ class UmaProtocolHelper @JvmOverloads constructor( travelRuleFormat: TravelRuleFormat? = null, requestedPayeeData: CounterPartyDataOptions? = null, comment: String? = null, + receiverUmaVersion: String = UMA_VERSION_STRING, ): PayRequest { val compliancePayerData = getSignedCompliancePayerData( receiverEncryptionPubKey, @@ -347,14 +368,22 @@ class UmaProtocolHelper @JvmOverloads constructor( email = payerEmail, compliance = compliancePayerData, ) - return PayRequest( - sendingCurrencyCode = if (isAmountInReceivingCurrency) receivingCurrencyCode else null, - payerData = payerData, - receivingCurrencyCode = receivingCurrencyCode, - amount = amount, - requestedPayeeData = requestedPayeeData, - comment = comment, - ) + if (Version.parse(receiverUmaVersion).major < 1) { + return PayRequestV0( + currencyCode = receivingCurrencyCode, + amount = amount, + payerData = payerData, + ) + } else { + return PayRequestV1( + sendingCurrencyCode = if (isAmountInReceivingCurrency) receivingCurrencyCode else null, + payerData = payerData, + receivingCurrencyCode = receivingCurrencyCode, + amount = amount, + requestedPayeeData = requestedPayeeData, + comment = comment, + ) + } } private fun getSignedCompliancePayerData( @@ -400,7 +429,7 @@ class UmaProtocolHelper @JvmOverloads constructor( */ @Throws(IllegalArgumentException::class) fun parseAsPayRequest(request: String): PayRequest { - return serialFormat.decodeFromString(request) + return serialFormat.decodeFromString(PayRequestSerializer, request) } /** @@ -458,6 +487,7 @@ class UmaProtocolHelper @JvmOverloads constructor( * intends its LNURL links to be stored it must return `disposable: false`. UMA should always return * `disposable: false`. See LUD-11. * @param successAction An action that the wallet should take once the payment is complete. See LUD-09. + * @param senderUmaVersion The UMA version of the sender VASP. This information can be obtained from the [LnurlpRequest]. * @return A [CompletableFuture] [PayReqResponse] that should be returned to the sender for the given [PayRequest]. */ @JvmOverloads @@ -477,6 +507,7 @@ class UmaProtocolHelper @JvmOverloads constructor( payeeData: PayeeData? = null, disposable: Boolean? = null, successAction: Map? = null, + senderUmaVersion: String = UMA_VERSION_STRING, ): CompletableFuture = coroutineScope.future { getPayReqResponse( query, @@ -493,6 +524,7 @@ class UmaProtocolHelper @JvmOverloads constructor( payeeData, disposable, successAction, + senderUmaVersion, ) } @@ -527,6 +559,7 @@ class UmaProtocolHelper @JvmOverloads constructor( * intends its LNURL links to be stored it must return `disposable: false`. UMA should always return * `disposable: false`. See LUD-11. * @param successAction An action that the wallet should take once the payment is complete. See LUD-09. + * @param senderUmaVersion The UMA version of the sender VASP. This information can be obtained from the [LnurlpRequest]. * @return A [PayReqResponse] that should be returned to the sender for the given [PayRequest]. */ @JvmOverloads @@ -546,6 +579,7 @@ class UmaProtocolHelper @JvmOverloads constructor( payeeData: PayeeData? = null, disposable: Boolean? = null, successAction: Map? = null, + senderUmaVersion: String = UMA_VERSION_STRING, ): PayReqResponse = runBlocking { val futureInvoiceCreator = object : UmaInvoiceCreator { override fun createUmaInvoice(amountMsats: Long, metadata: String): CompletableFuture { @@ -567,6 +601,7 @@ class UmaProtocolHelper @JvmOverloads constructor( payeeData, disposable, successAction, + senderUmaVersion, ) } @@ -598,6 +633,7 @@ class UmaProtocolHelper @JvmOverloads constructor( * intends its LNURL links to be stored it must return `disposable: false`. UMA should always return * `disposable: false`. See LUD-11. * @param successAction An action that the wallet should take once the payment is complete. See LUD-09. + * @param senderUmaVersion The UMA version of the sender VASP. This information can be obtained from the [LnurlpRequest]. * @return The [PayReqResponse] that should be returned to the sender for the given [PayRequest]. */ @JvmName("KotlinOnly-getPayReqResponseSuspended") @@ -616,10 +652,11 @@ class UmaProtocolHelper @JvmOverloads constructor( payeeData: PayeeData? = null, disposable: Boolean? = null, successAction: Map? = null, + senderUmaVersion: String = UMA_VERSION_STRING, ): PayReqResponse { val encodedPayerData = query.payerData?.let { serialFormat.encodeToString(query.payerData) } ?: "" val metadataWithPayerData = "$metadata$encodedPayerData" - if (query.sendingCurrencyCode != null && query.sendingCurrencyCode != receivingCurrencyCode) { + if (query.sendingCurrencyCode() != null && query.sendingCurrencyCode() != receivingCurrencyCode) { throw IllegalArgumentException( "Currency code in the pay request must match the receiving currency if not null.", ) @@ -639,7 +676,7 @@ class UmaProtocolHelper @JvmOverloads constructor( throw IllegalArgumentException("Missing required fields for UMA: $missingFields") } } - val isAmountInMsats = query.sendingCurrencyCode == null + val isAmountInMsats = query.sendingCurrencyCode() == null val receivingCurrencyAmount = if (isAmountInMsats) { ((query.amount.toDouble() - (receiverFeesMillisats ?: 0)) / (conversionRate ?: 1.0)).roundToLong() } else { @@ -666,24 +703,36 @@ class UmaProtocolHelper @JvmOverloads constructor( ), ) } - return PayReqResponse( + val paymentInfo = if ( + receivingCurrencyCode != null && + receivingCurrencyDecimals != null && + conversionRate != null + ) { + PayReqResponsePaymentInfo( + amount = receivingCurrencyAmount, + currencyCode = receivingCurrencyCode, + decimals = receivingCurrencyDecimals, + multiplier = conversionRate, + exchangeFeesMillisatoshi = receiverFeesMillisats ?: 0, + ) + } else { + null + } + if (Version.parse(senderUmaVersion).major < 1) { + return PayReqResponseV0( + encodedInvoice = invoice, + compliance = PayReqResponseCompliance( + utxos = receiverChannelUtxos ?: emptyList(), + nodePubKey = receiverNodePubKey, + utxoCallback = utxoCallback ?: "", + ), + paymentInfo = paymentInfo ?: throw IllegalArgumentException("Payment info is required for UMAv0"), + ) + } + return PayReqResponseV1( encodedInvoice = invoice, payeeData = if (query.isUmaRequest()) JsonObject(mutablePayeeData) else null, - paymentInfo = if ( - receivingCurrencyCode != null && - receivingCurrencyDecimals != null && - conversionRate != null - ) { - PayReqResponsePaymentInfo( - amount = receivingCurrencyAmount, - currencyCode = receivingCurrencyCode, - decimals = receivingCurrencyDecimals, - multiplier = conversionRate, - exchangeFeesMillisatoshi = receiverFeesMillisats ?: 0, - ) - } else { - null - }, + paymentInfo = paymentInfo, disposable = disposable, successAction = successAction, ) @@ -713,7 +762,7 @@ class UmaProtocolHelper @JvmOverloads constructor( } fun parseAsPayReqResponse(response: String): PayReqResponse { - return serialFormat.decodeFromString(response) + return serialFormat.decodeFromString(PayReqResponseSerializer, response) } /** @@ -733,6 +782,7 @@ class UmaProtocolHelper @JvmOverloads constructor( payerIdentifier: String, nonceCache: NonceCache, ): Boolean { + if (payReqResponse !is PayReqResponseV1) return true if (!payReqResponse.isUmaResponse()) return false val compliance = payReqResponse.payeeData?.payeeCompliance() ?: return false nonceCache.checkAndSaveNonce(compliance.signatureNonce, compliance.signatureTimestamp) diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt index 15b5fa7..f1395dd 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt @@ -1,37 +1,35 @@ +@file:JvmName("CurrencyUtils") + package me.uma.protocol import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import me.uma.utils.serialFormat +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject +import me.uma.UMA_VERSION_STRING +import me.uma.Version -@Serializable -data class Currency( +sealed interface Currency { /** * The currency code, eg. "USD". */ - val code: String, + val code: String /** * The full currency name, eg. "US Dollars". */ - val name: String, + val name: String /** * The symbol of the currency, eg. "$". */ - val symbol: String, + val symbol: String /** * Estimated millisats per smallest "unit" of this currency (eg. 1 cent in USD). */ - @SerialName("multiplier") - val millisatoshiPerUnit: Double, - - /** - * The minimum and maximum amounts that can be sent in this currency and converted from SATs by the receiver. - */ - val convertible: CurrencyConvertible, + val millisatoshiPerUnit: Double /** * The number of digits after the decimal point for display on the sender side, and to add clarity @@ -43,16 +41,120 @@ data class Currency( * * For details on edge cases and examples, see https://github.com/uma-universal-money-address/protocol/blob/main/umad-04-lnurlp-response.md. */ - val decimals: Int, -) { - fun toJson() = serialFormat.encodeToString(this) + val decimals: Int + + fun minSendable(): Long + + fun maxSendable(): Long +} + +/** + * Creates a [Currency] which contains information about currencies that the VASP is able to receive. + * + * @param code The currency code, eg. "USD". + * @param name The full currency name, eg. "US Dollars". + * @param symbol The symbol of the currency, eg. "$". + * @param millisatoshiPerUnit The conversion rate from the smallest unit of the currency to millisatoshis. + * @param decimals The number of decimal places in the currency. + * @param minSendable Minimum amount that can be sent in this currency. This is in the smallest unit of the + * currency (eg. cents for USD). + * @param maxSendable Maximum amount that can be sent in this currency. This is in the smallest unit of the + * currency (eg. cents for USD). + * @param senderUmaVersion The UMA version of the sender VASP. This information can be obtained from the + * [LnurlpRequest]. + * @return the [Currency] to be sent to the sender VASP. + */ +@JvmOverloads +fun createCurrency( + code: String, + name: String, + symbol: String, + millisatoshiPerUnit: Double, + decimals: Int, + minSendable: Long, + maxSendable: Long, + senderUmaVersion: String = UMA_VERSION_STRING, +): Currency { + return if (Version.parse(senderUmaVersion).major < 1) { + CurrencyV0( + code = code, + name = name, + symbol = symbol, + millisatoshiPerUnit = millisatoshiPerUnit, + minSendable = minSendable, + maxSendable = maxSendable, + decimals = decimals, + ) + } else { + CurrencyV1( + code = code, + name = name, + symbol = symbol, + millisatoshiPerUnit = millisatoshiPerUnit, + convertible = CurrencyConvertible( + min = minSendable, + max = maxSendable, + ), + decimals = decimals, + ) + } +} + +@Serializable +internal data class CurrencyV1( + override val code: String, + override val name: String, + override val symbol: String, + @SerialName("multiplier") + override val millisatoshiPerUnit: Double, + + /** + * The minimum and maximum amounts that can be sent in this currency and converted from SATs by + * the receiver. + */ + val convertible: CurrencyConvertible, + + override val decimals: Int, +) : Currency { + + override fun minSendable() = convertible.min + + override fun maxSendable() = convertible.max +} + +@Serializable +internal data class CurrencyV0( + override val code: String, + override val name: String, + override val symbol: String, + @SerialName("multiplier") + override val millisatoshiPerUnit: Double, + + /** + * Minimum amount that can be sent in this currency. This is in the smallest unit of the + * currency (eg. cents for USD). + */ + val minSendable: Long, + + /** + * Maximum amount that can be sent in this currency. This is in the smallest unit of the + * currency (eg. cents for USD). + */ + val maxSendable: Long, + + override val decimals: Int, +) : Currency { + + override fun minSendable() = minSendable + + override fun maxSendable() = maxSendable } /** * The `convertible` field of the [Currency] object. */ @Serializable -data class CurrencyConvertible( +internal data class CurrencyConvertible( /** * Minimum amount that can be sent in this currency. This is in the smallest unit of the currency * (eg. cents for USD). @@ -64,3 +166,10 @@ data class CurrencyConvertible( */ val max: Long, ) + +object CurrencySerializer : JsonContentPolymorphicSerializer(Currency::class) { + override fun selectDeserializer(element: JsonElement) = when { + "minSendable" in element.jsonObject -> CurrencyV0.serializer() + else -> CurrencyV1.serializer() + } +} diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpResponse.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpResponse.kt index 39f0f8e..4ab085b 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpResponse.kt @@ -30,7 +30,7 @@ data class LnurlpResponse( val minSendable: Long, val maxSendable: Long, val metadata: String, - val currencies: List?, + val currencies: List<@Serializable(with = CurrencySerializer::class) Currency>?, @SerialName("payerData") val requiredPayerData: CounterPartyDataOptions?, val compliance: LnurlComplianceResponse?, @@ -77,7 +77,7 @@ data class UmaLnurlpResponse( val minSendable: Long, val maxSendable: Long, val metadata: String, - val currencies: List, + val currencies: List<@Serializable(with = CurrencySerializer::class) Currency>, @SerialName("payerData") val requiredPayerData: CounterPartyDataOptions, val compliance: LnurlComplianceResponse, diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt index a94fae8..59b6f4e 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt @@ -1,37 +1,72 @@ package me.uma.protocol import kotlinx.serialization.* +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject import me.uma.utils.serialFormat /** * The response sent by the receiver to the sender to provide an invoice. - * - * @property encodedInvoice The BOLT11 invoice that the sender will pay. - * @property paymentInfo Information about the payment that the receiver will receive. Includes - * Final currency-related information for the payment. Required for UMA. - * @property payeeData The data about the receiver that the sending VASP requested in the payreq request. - * Required for UMA. - * @property routes Usually just an empty list from legacy LNURL, which was replaced by route hints in the BOLT11 - * invoice. - * @property disposable This field may be used by a WALLET to decide whether the initial LNURL link will - * be stored locally for later reuse or erased. If disposable is null, it should be - * interpreted as true, so if SERVICE intends its LNURL links to be stored it must - * return `disposable: false`. UMA should always return `disposable: false`. See LUD-11. - * @property successAction Defines a struct which can be stored and shown to the user on payment success. See LUD-09. */ +sealed interface PayReqResponse { + /** + * The BOLT11 invoice that the sender will pay. + */ + val encodedInvoice: String + + /** + * Information about the payment that the receiver will receive. Includes final currency-related + * information for the payment. Required for UMA. + */ + val paymentInfo: PayReqResponsePaymentInfo? + + /** + * Usually just an empty list from legacy LNURL, which was replaced by route hints in the BOLT11 + * invoice. + */ + val routes: List + + fun isUmaResponse(): Boolean + + fun toJson(): String +} + @OptIn(ExperimentalSerializationApi::class) @Serializable -data class PayReqResponse( +internal data class PayReqResponseV1( @SerialName("pr") - val encodedInvoice: String, - val paymentInfo: PayReqResponsePaymentInfo?, + override val encodedInvoice: String, + override val paymentInfo: PayReqResponsePaymentInfo?, + + /** + * The data about the receiver that the sending VASP requested in the payreq request. + * Required for UMA. + */ val payeeData: PayeeData?, + @EncodeDefault - val routes: List = emptyList(), + override val routes: List = emptyList(), + + /** + * This field may be used by a WALLET to decide whether the initial LNURL link will + * be stored locally for later reuse or erased. If disposable is null, it should be + * interpreted as true, so if SERVICE intends its LNURL links to be stored it must + * return `disposable: false`. UMA should always return `disposable: false`. See LUD-11. + */ val disposable: Boolean? = null, + + /** + * Defines a struct which can be stored and shown to the user on payment success. See LUD-09. + */ val successAction: Map? = null, -) { - fun toJson() = serialFormat.encodeToString(this) +) : PayReqResponse { + override fun toJson() = serialFormat.encodeToString(this) + + override fun isUmaResponse() = payeeData != null && + payeeData.payeeCompliance() != null && + payeeData.identifier() != null && + paymentInfo != null fun signablePayload(payerIdentifier: String): ByteArray { if (payeeData == null) throw IllegalArgumentException("Payee data is required for UMA") @@ -43,11 +78,25 @@ data class PayReqResponse( .encodeToByteArray() } } +} - fun isUmaResponse() = payeeData != null && - payeeData.payeeCompliance() != null && - payeeData.identifier() != null && - paymentInfo != null +@Serializable +internal data class PayReqResponseV0( + @SerialName("pr") + override val encodedInvoice: String, + + /** + * The compliance data from the receiver, including utxo info. + */ + val compliance: PayReqResponseCompliance, + + override val paymentInfo: PayReqResponsePaymentInfo, + @EncodeDefault + override val routes: List = emptyList(), +) : PayReqResponse { + override fun isUmaResponse() = true + + override fun toJson() = serialFormat.encodeToString(this) } @Serializable @@ -83,10 +132,32 @@ data class RouteHop( */ @Serializable data class PayReqResponsePaymentInfo( - val amount: Long, + val amount: Long? = null, val currencyCode: String, val decimals: Int, val multiplier: Double, - @SerialName("fee") val exchangeFeesMillisatoshi: Long, ) + +/** + * The compliance data from the receiver, including utxo info. + * + * @property utxos A list of UTXOs of channels over which the receiver will likely receive the payment. + * @property nodePubKey If known, the public key of the receiver's node. If supported by the sending VASP's compliance + * provider, this will be used to pre-screen the receiver's UTXOs for compliance purposes. + * @property utxoCallback The URL that the sender VASP will call to send UTXOs of the channel that the sender used to + * send the payment once it completes. + */ +@Serializable +data class PayReqResponseCompliance( + val utxos: List, + val nodePubKey: String?, + val utxoCallback: String, +) + +object PayReqResponseSerializer : JsonContentPolymorphicSerializer(PayReqResponse::class) { + override fun selectDeserializer(element: JsonElement) = when { + "compliance" in element.jsonObject -> PayReqResponseV0.serializer() + else -> PayReqResponseV1.serializer() + } +} diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt index 71d5f71..512fd8c 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt @@ -2,6 +2,7 @@ package me.uma.protocol import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.nullable @@ -11,32 +12,103 @@ import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.descriptors.element import kotlinx.serialization.encodeToString import kotlinx.serialization.encoding.* +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject import me.uma.utils.serialFormat /** * The request sent by the sender to the receiver to retrieve an invoice. - * - * @property sendingCurrencyCode The currency code in which the amount field is specified. If null, the - * amount is assumed to be specified in msats. - * @property receivingCurrencyCode The currency code that the receiver will receive for this payment. - * @property amount The amount that the receiver will receive in either the smallest unit of the sendingCurrencyCode - * or in msats (if sendingCurrencyCode is null). - * @property payerData The data that the sender will send to the receiver to identify themselves. - * @property requestedPayeeData The data that the sender requests the receiver to send to identify themselves. - * @property comment A comment that the sender would like to include with the payment. This can only be included - * if the receiver included the `commentAllowed` field in the lnurlp response. The length of - * the comment must be less than or equal to the value of `commentAllowed`. */ -@Serializable(with = PayRequestSerializer::class) -data class PayRequest @JvmOverloads constructor( +sealed interface PayRequest { + /** + * The amount that the receiver will receive in either the smallest unit of the + * sendingCurrencyCode or in msats (if sendingCurrencyCode is null). + */ + val amount: Long + + /** + * The data that the sender will send to the receiver to identify themselves. + */ + val payerData: PayerData? + + /** + * The currency code in which the amount field is specified. If null, the + * amount is assumed to be specified in msats. + */ + fun sendingCurrencyCode(): String? + + /** + * The currency code that the receiver will receive for this payment. + */ + fun receivingCurrencyCode(): String? + + fun isUmaRequest(): Boolean + + fun signablePayload(): ByteArray + + fun toJson(): String + + fun requestedPayeeData(): CounterPartyDataOptions? + + fun toQueryParamMap(): Map> + + companion object { + fun fromQueryParamMap(queryMap: Map>): PayRequest { + val receivingCurrencyCode = queryMap["convert"]?.firstOrNull() + + val amountStr = queryMap["amount"]?.firstOrNull() + ?: throw IllegalArgumentException("Amount is required") + val parts = amountStr.split(".") + val sendingCurrencyCode = if (parts.size == 2) parts[1] else null + val amount = parts[0].toLong() + + val payerData = + queryMap["payerData"]?.firstOrNull()?.let { serialFormat.decodeFromString(PayerData.serializer(), it) } + val requestedPayeeData = queryMap["payeeData"]?.firstOrNull()?.let { + serialFormat.decodeFromString( + MapSerializer(String.serializer(), CounterPartyDataOption.serializer()), + it, + ) + } + val comment = queryMap["comment"]?.firstOrNull() + return PayRequestV1( + sendingCurrencyCode, + receivingCurrencyCode, + amount, + payerData, + requestedPayeeData, + comment, + ) + } + } +} + +@Serializable +internal data class PayRequestV1( val sendingCurrencyCode: String?, val receivingCurrencyCode: String?, - val amount: Long, - val payerData: PayerData?, + override val amount: Long, + override val payerData: PayerData?, + + /** + * The data that the sender requests the receiver to send to identify themselves. + */ val requestedPayeeData: CounterPartyDataOptions? = null, + + /** + * A comment that the sender would like to include with the payment. This can only be included + * if the receiver included the `commentAllowed` field in the lnurlp response. The length of + * the comment must be less than or equal to the value of `commentAllowed`. + */ val comment: String? = null, -) { - fun signablePayload(): ByteArray { +) : PayRequest { + + override fun receivingCurrencyCode() = receivingCurrencyCode + + override fun sendingCurrencyCode() = sendingCurrencyCode + + override fun signablePayload(): ByteArray { if (payerData == null) throw IllegalArgumentException("Payer data is required for UMA") if (payerData.identifier() == null) throw IllegalArgumentException("Payer identifier is required for UMA") val complianceData = payerData.compliance() ?: throw IllegalArgumentException("Compliance data is required") @@ -45,11 +117,13 @@ data class PayRequest @JvmOverloads constructor( } } - fun isUmaRequest() = payerData != null && payerData.compliance() != null && payerData.identifier() != null + override fun isUmaRequest() = payerData != null && payerData.compliance() != null && payerData.identifier() != null - fun toJson() = serialFormat.encodeToString(this) + override fun toJson() = serialFormat.encodeToString(PayRequestV1Serializer, this) - fun toQueryParamMap(): Map> { + override fun requestedPayeeData(): CounterPartyDataOptions? = requestedPayeeData + + override fun toQueryParamMap(): Map> { val amountStr = if (sendingCurrencyCode != null) { "$amount.$sendingCurrencyCode" } else { @@ -66,49 +140,53 @@ data class PayRequest @JvmOverloads constructor( comment?.let { map["comment"] = listOf(it) } return map } +} - companion object { - fun fromQueryParamMap(queryMap: Map>): PayRequest { - val receivingCurrencyCode = queryMap["convert"]?.firstOrNull() +@Serializable +internal data class PayRequestV0( + /** + * The currency code that the receiver will receive for this payment. + */ + @SerialName("currency") + val currencyCode: String, - val amountStr = queryMap["amount"]?.firstOrNull() - ?: throw IllegalArgumentException("Amount is required") - val parts = amountStr.split(".") - val sendingCurrencyCode = if (parts.size == 2) parts[1] else null - val amount = parts[0].toLong() + override val amount: Long, + override val payerData: PayerData, +) : PayRequest { + override fun receivingCurrencyCode() = currencyCode - val payerData = - queryMap["payerData"]?.firstOrNull()?.let { serialFormat.decodeFromString(PayerData.serializer(), it) } - val requestedPayeeData = queryMap["payeeData"]?.firstOrNull()?.let { - serialFormat.decodeFromString( - MapSerializer(String.serializer(), CounterPartyDataOption.serializer()), - it, - ) - } - val comment = queryMap["comment"]?.firstOrNull() - return PayRequest( - sendingCurrencyCode, - receivingCurrencyCode, - amount, - payerData, - requestedPayeeData, - comment, - ) - } - } + override fun sendingCurrencyCode() = null + + override fun isUmaRequest() = true + + override fun requestedPayeeData() = null + + override fun signablePayload() = + payerData.compliance()?.let { + "${payerData.identifier()}|${it.signatureNonce}|${it.signatureTimestamp}".encodeToByteArray() + } ?: payerData.identifier()?.encodeToByteArray() + ?: throw IllegalArgumentException("Payer identifier is required for UMA") + + override fun toJson() = serialFormat.encodeToString(this) + + override fun toQueryParamMap() = mapOf( + "amount" to listOf(amount.toString()), + "convert" to listOf(currencyCode), + "payerData" to listOf(serialFormat.encodeToString(payerData)), + ) } @OptIn(ExperimentalSerializationApi::class) -object PayRequestSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PayRequest") { - element("convert") +internal object PayRequestV1Serializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PayRequestV1") { + element("convert", isOptional = true) element("amount") // Serialize and deserialize amount as a string element("payerData") - element("payeeData") - element("comment") + element("payeeData", isOptional = true) + element("comment", isOptional = true) } - override fun serialize(encoder: Encoder, value: PayRequest) { + override fun serialize(encoder: Encoder, value: PayRequestV1) { encoder.encodeStructure(descriptor) { value.receivingCurrencyCode?.let { encodeStringElement(descriptor, 0, it) } encodeStringElement( @@ -131,7 +209,7 @@ object PayRequestSerializer : KSerializer { } } - override fun deserialize(decoder: Decoder): PayRequest { + override fun deserialize(decoder: Decoder): PayRequestV1 { var sendingCurrencyCode: String? = null var receivingCurrencyCode: String? = null var amount: String? = null @@ -170,7 +248,7 @@ object PayRequestSerializer : KSerializer { } } ?: throw IllegalArgumentException("Amount is required") - PayRequest( + PayRequestV1( sendingCurrencyCode, receivingCurrencyCode, parsedAmount, @@ -181,3 +259,10 @@ object PayRequestSerializer : KSerializer { } } } + +object PayRequestSerializer : JsonContentPolymorphicSerializer(PayRequest::class) { + override fun selectDeserializer(element: JsonElement) = when { + "currency" in element.jsonObject -> PayRequestV0.serializer() + else -> PayRequestV1Serializer + } +} diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt index cc0bb81..dd39958 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt @@ -23,12 +23,12 @@ import me.uma.utils.serialFormat */ @Serializable data class PubKeyResponse internal constructor( - val signingCertChain: List<@Serializable(with = X509CertificateSerializer::class) X509Certificate>?, - val encryptionCertChain: List<@Serializable(with = X509CertificateSerializer::class) X509Certificate>?, + val signingCertChain: List<@Serializable(with = X509CertificateSerializer::class) X509Certificate>? = null, + val encryptionCertChain: List<@Serializable(with = X509CertificateSerializer::class) X509Certificate>? = null, @Serializable(with = ByteArrayAsHexSerializer::class) - private val signingPubKey: ByteArray?, + private val signingPubKey: ByteArray? = null, @Serializable(with = ByteArrayAsHexSerializer::class) - private val encryptionPubKey: ByteArray?, + private val encryptionPubKey: ByteArray? = null, val expirationTimestamp: Long? = null, ) { @JvmOverloads diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/utils/Serialization.kt b/uma-sdk/src/commonMain/kotlin/me/uma/utils/Serialization.kt index 8c8d310..c225ebf 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/utils/Serialization.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/utils/Serialization.kt @@ -2,10 +2,31 @@ package me.uma.utils import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import me.uma.protocol.Currency +import me.uma.protocol.CurrencyV0 +import me.uma.protocol.CurrencyV1 +import me.uma.protocol.PayReqResponse +import me.uma.protocol.PayReqResponseV0 +import me.uma.protocol.PayReqResponseV1 +import me.uma.protocol.PayRequest +import me.uma.protocol.PayRequestV0 +import me.uma.protocol.PayRequestV1 +import me.uma.protocol.PayRequestV1Serializer + +val module = SerializersModule { + polymorphic(Currency::class, CurrencyV1::class, CurrencyV1.serializer()) + polymorphic(Currency::class, CurrencyV0::class, CurrencyV0.serializer()) + polymorphic(PayRequest::class, PayRequestV1::class, PayRequestV1Serializer) + polymorphic(PayRequest::class, PayRequestV0::class, PayRequestV0.serializer()) + polymorphic(PayReqResponse::class, PayReqResponseV1::class, PayReqResponseV1.serializer()) + polymorphic(PayReqResponse::class, PayReqResponseV0::class, PayReqResponseV0.serializer()) +} @OptIn(ExperimentalSerializationApi::class) val serialFormat = Json { ignoreUnknownKeys = true isLenient = true explicitNulls = false + serializersModule = module } diff --git a/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt b/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt index e1f8e22..af27215 100644 --- a/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt +++ b/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt @@ -3,13 +3,17 @@ package me.uma import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlin.test.fail import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonPrimitive import me.uma.crypto.Secp256k1 +import me.uma.crypto.hexToByteArray import me.uma.protocol.KycStatus +import me.uma.protocol.PayRequestV0 +import me.uma.protocol.PayRequestV1 import me.uma.protocol.TravelRuleFormat import me.uma.protocol.compliance import me.uma.protocol.createCounterPartyDataOptions @@ -39,9 +43,11 @@ class UmaTests { "name" to false, "compliance" to true, ), + receiverUmaVersion = "1.0", ) - assertEquals("USD", payreq.receivingCurrencyCode) - assertEquals("USD", payreq.sendingCurrencyCode) + assertTrue(payreq is PayRequestV1) + assertEquals("USD", payreq.receivingCurrencyCode()) + assertEquals("USD", payreq.sendingCurrencyCode()) val json = payreq.toJson() val jsonObject = serialFormat.decodeFromString(JsonObject.serializer(), json) assertEquals("100.USD", jsonObject["amount"]?.jsonPrimitive?.content) @@ -69,8 +75,11 @@ class UmaTests { payerIdentifier = "test@test.com", payerKycStatus = KycStatus.VERIFIED, utxoCallback = "https://example.com/utxo", + receiverUmaVersion = "1.0", ) - assertNull(payreq.sendingCurrencyCode) + assertTrue(payreq is PayRequestV1) + assertNull(payreq.sendingCurrencyCode()) + assertEquals("USD", payreq.receivingCurrencyCode()) val json = payreq.toJson() val jsonObject = serialFormat.decodeFromString(JsonObject.serializer(), json) assertEquals("100", jsonObject["amount"]?.jsonPrimitive?.content) @@ -78,4 +87,26 @@ class UmaTests { val decodedPayReq = UmaProtocolHelper().parseAsPayRequest(json) assertEquals(payreq, decodedPayReq) } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun `test create and parse payreq umav0`() = runTest { + val payreq = UmaProtocolHelper().getPayRequest( + receiverEncryptionPubKey = keys.publicKey, + sendingVaspPrivateKey = keys.privateKey, + receivingCurrencyCode = "USD", + amount = 100, + isAmountInReceivingCurrency = false, + payerIdentifier = "test@test.com", + payerKycStatus = KycStatus.VERIFIED, + utxoCallback = "https://example.com/utxo", + receiverUmaVersion = "0.3", + ) + assertTrue(payreq is PayRequestV0) + assertNull(payreq.sendingCurrencyCode()) + assertEquals("USD", payreq.receivingCurrencyCode()) + val json = payreq.toJson() + val decodedPayReq = UmaProtocolHelper().parseAsPayRequest(json) + assertEquals(payreq, decodedPayReq) + } }