From 569ca023b45888a62dbbe679b94e9db25a0ee4cb Mon Sep 17 00:00:00 2001 From: shreyav Date: Mon, 18 Mar 2024 10:10:51 -0700 Subject: [PATCH 01/13] v1 to v0 lookup --- .../test/java/me/uma/javatest/UmaTest.java | 65 ++++++++++++++++- .../kotlin/me/uma/UmaProtocolHelper.kt | 9 +++ .../kotlin/me/uma/protocol/Currency.kt | 69 ++++++++++++++----- .../kotlin/me/uma/protocol/LnurlpResponse.kt | 4 +- .../kotlin/me/uma/protocol/PubKeyResponse.kt | 8 +-- .../kotlin/me/uma/utils/Serialization.kt | 10 ++- 6 files changed, 140 insertions(+), 25 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..fe2846b 100644 --- a/javatest/src/test/java/me/uma/javatest/UmaTest.java +++ b/javatest/src/test/java/me/uma/javatest/UmaTest.java @@ -108,7 +108,16 @@ public void testGetLnurlpResponse() throws Exception { ) ), List.of( - new Currency( +// new Currency.CurrencyV0( +// "USD", +// "US Dollar", +// "$", +// 34_150, +// 1, +// 10_000_000, +// 2 +// ) + new Currency.CurrencyV1( "USD", "US Dollar", "$", @@ -122,6 +131,7 @@ public void testGetLnurlpResponse() throws Exception { assertNotNull(lnurlpResponse); String responseJson = lnurlpResponse.toJson(); System.out.println(responseJson); + System.out.println(lnurlpResponse.getCurrencies()); LnurlpResponse parsedResponse = umaProtocolHelper.parseAsLnurlpResponse(responseJson); assertNotNull(parsedResponse); assertEquals(lnurlpResponse, parsedResponse); @@ -130,6 +140,59 @@ public void testGetLnurlpResponse() throws Exception { new InMemoryNonceCache(1L))); } + @Test + public void testGetCurrency() throws Exception { + String lnurlpUrl = umaProtocolHelper.getSignedLnurlpRequestUrl( + privateKeyBytes(), + "$bob@vasp2.com", + "https://vasp.com", + true); + LnurlpRequest request = umaProtocolHelper.parseLnurlpRequest(lnurlpUrl); + assertNotNull(request); + + Currency currency = new Currency.CurrencyV1( + "USD", + "US Dollar", + "$", + 34_150, + new CurrencyConvertible(1, 10_000_000), + 2 + ); + + Currency currencyv0 = new Currency.CurrencyV0( + "USD", + "US Dollar", + "$", + 34_150, + 1, + 10_000_000, + 2 + ); + String responseJson = umaProtocolHelper.encodeAsCurrency(currencyv0); + System.out.println("hi" + responseJson); + Currency parsedResponse = umaProtocolHelper.parseAsCurrency(responseJson); + assertNotNull(parsedResponse); + assertEquals(currencyv0, parsedResponse); + } + + @Test + public void testGetCurrencyV0() throws Exception { + Currency currencyv0 = new Currency.CurrencyV0( + "USD", + "US Dollar", + "$", + 34_150, + 1, + 10_000_000, + 2 + ); + String responseJson = "{\"code\":\"USD\",\"name\":\"US Dollar\",\"symbol\":\"$\",\"multiplier\":34150.0,\"minSendable\":1,\"maxSendable\":10000000,\"decimals\":2}"; + System.out.println("hi" + responseJson); + Currency parsedResponse = umaProtocolHelper.parseAsCurrency(responseJson); + assertNotNull(parsedResponse); + assertEquals(currencyv0, parsedResponse); + } + @Test public void testGetPayReqResponseSync() throws Exception { PayRequest request = umaProtocolHelper.getPayRequest( diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt index b77c6be..e34bf66 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.encodeToJsonElement import me.uma.crypto.Secp256k1 @@ -258,6 +259,14 @@ class UmaProtocolHelper @JvmOverloads constructor( return serialFormat.decodeFromString(response) } + fun parseAsCurrency(response: String): Currency { + return serialFormat.decodeFromString(CurrencySerializer, response) + } + + fun encodeAsCurrency(currency: Currency): String { + return serialFormat.encodeToString(CurrencySerializer, currency) + } + /** * Verifies the signature on an UMA Lnurlp response based on the public key of the VASP making the request. * 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..e0cc30c 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,32 @@ package me.uma.protocol +import kotlinx.serialization.DeserializationStrategy 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 -@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,9 +38,42 @@ 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 + + @Serializable + 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 + + @Serializable + 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 } /** @@ -64,3 +92,10 @@ data class CurrencyConvertible( */ val max: Long, ) + +object CurrencySerializer : JsonContentPolymorphicSerializer(Currency::class) { + override fun selectDeserializer(element: JsonElement) = when { + "minSendable" in element.jsonObject -> Currency.CurrencyV0.serializer() + else -> Currency.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/PubKeyResponse.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt index cc0bb81..cc0b955 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt @@ -14,8 +14,8 @@ import me.uma.utils.serialFormat * * @property signingCertChain list of X.509 certificates. The order of the certificates is from the * leaf to the root. Used to verify signatures from the VASP. - * @property encryptionCertChain list of X.509 certificates. The order of the certificates is from the - * leaf to the root. Used to encrypt TR info sent to the VASP. + * @property encryptionCertChain list of X.509 certificates. The order of the certificates is from + * the leaf to the root. Used to encrypt TR info sent to the VASP. * @property signingPubKey The public key used to verify signatures from the VASP. * @property encryptionPubKey The public key used to encrypt TR info sent to the VASP. * @property expirationTimestamp Seconds since epoch at which these pub keys must be refreshed. @@ -23,8 +23,8 @@ 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?, @Serializable(with = ByteArrayAsHexSerializer::class) 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 ebc6af6..5e37b60 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/utils/Serialization.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/utils/Serialization.kt @@ -1,8 +1,16 @@ package me.uma.utils import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import me.uma.protocol.Currency + +val module = SerializersModule { + polymorphic(Currency::class, Currency.CurrencyV1::class, Currency.CurrencyV1.serializer()) + polymorphic(Currency::class, Currency.CurrencyV0::class, Currency.CurrencyV0.serializer()) +} val serialFormat = Json { ignoreUnknownKeys = true isLenient = true -} \ No newline at end of file + serializersModule = module +} From 3bdcce592ddddfeb07eccccb27d08163d75bf740 Mon Sep 17 00:00:00 2001 From: shreyav Date: Mon, 18 Mar 2024 16:35:16 -0700 Subject: [PATCH 02/13] rest --- .../kotlin/me/uma/UmaProtocolHelper.kt | 109 +++++++++++++----- .../kotlin/me/uma/protocol/Currency.kt | 72 ++++++------ .../kotlin/me/uma/protocol/PayReqResponse.kt | 65 +++++++++-- .../kotlin/me/uma/protocol/PayRequest.kt | 71 +++++++++--- .../kotlin/me/uma/utils/Serialization.kt | 17 ++- 5 files changed, 244 insertions(+), 90 deletions(-) diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt index e34bf66..1b5e7b2 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.encodeToJsonElement import me.uma.crypto.Secp256k1 @@ -220,12 +219,27 @@ 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, @@ -338,6 +352,7 @@ class UmaProtocolHelper @JvmOverloads constructor( travelRuleFormat: TravelRuleFormat? = null, requestedPayeeData: CounterPartyDataOptions? = null, comment: String? = null, + receiverUmaVersion: String = UMA_VERSION_STRING, ): PayRequest { val compliancePayerData = getSignedCompliancePayerData( receiverEncryptionPubKey, @@ -356,14 +371,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( @@ -409,7 +432,11 @@ class UmaProtocolHelper @JvmOverloads constructor( */ @Throws(IllegalArgumentException::class) fun parseAsPayRequest(request: String): PayRequest { - return serialFormat.decodeFromString(request) + return serialFormat.decodeFromString(PayRequestSerializer, request) + } + + fun encodePayReq(payReq: PayRequest): String { + return serialFormat.encodeToString(PayRequestSerializer, payReq) } /** @@ -486,6 +513,7 @@ class UmaProtocolHelper @JvmOverloads constructor( payeeData: PayeeData? = null, disposable: Boolean? = null, successAction: Map? = null, + senderUmaVersion: String = UMA_VERSION_STRING, ): CompletableFuture = coroutineScope.future { getPayReqResponse( query, @@ -502,6 +530,7 @@ class UmaProtocolHelper @JvmOverloads constructor( payeeData, disposable, successAction, + senderUmaVersion, ) } @@ -555,6 +584,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 { @@ -576,6 +606,7 @@ class UmaProtocolHelper @JvmOverloads constructor( payeeData, disposable, successAction, + senderUmaVersion, ) } @@ -625,10 +656,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.", ) @@ -648,7 +680,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 { @@ -675,24 +707,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, ) @@ -722,7 +766,11 @@ class UmaProtocolHelper @JvmOverloads constructor( } fun parseAsPayReqResponse(response: String): PayReqResponse { - return serialFormat.decodeFromString(response) + return serialFormat.decodeFromString(PayReqResponseSerializer, response) + } + + fun encodePayReqResponse(payReqResponse: PayReqResponse): String { + return serialFormat.encodeToString(PayReqResponseSerializer, payReqResponse) } /** @@ -742,6 +790,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 e0cc30c..f7ca00f 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt @@ -1,6 +1,5 @@ package me.uma.protocol -import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonContentPolymorphicSerializer @@ -39,42 +38,43 @@ sealed interface 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 +} - @Serializable - 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 - @Serializable - 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, +@Serializable +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 - /** - * 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 -} +@Serializable +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 /** * The `convertible` field of the [Currency] object. @@ -95,7 +95,7 @@ data class CurrencyConvertible( object CurrencySerializer : JsonContentPolymorphicSerializer(Currency::class) { override fun selectDeserializer(element: JsonElement) = when { - "minSendable" in element.jsonObject -> Currency.CurrencyV0.serializer() - else -> Currency.CurrencyV1.serializer() + "minSendable" in element.jsonObject -> CurrencyV0.serializer() + else -> CurrencyV1.serializer() } } 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..8998efc 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt @@ -1,8 +1,20 @@ 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 +sealed interface PayReqResponse { + val encodedInvoice: String + val routes: List + val paymentInfo: PayReqResponsePaymentInfo? + + fun isUmaResponse(): Boolean + fun toJson(): String +} + /** * The response sent by the receiver to the sender to provide an invoice. * @@ -21,17 +33,17 @@ import me.uma.utils.serialFormat */ @OptIn(ExperimentalSerializationApi::class) @Serializable -data class PayReqResponse( +data class PayReqResponseV1( @SerialName("pr") - val encodedInvoice: String, - val paymentInfo: PayReqResponsePaymentInfo?, + override val encodedInvoice: String, + override val paymentInfo: PayReqResponsePaymentInfo?, val payeeData: PayeeData?, @EncodeDefault - val routes: List = emptyList(), + override val routes: List = emptyList(), val disposable: Boolean? = null, val successAction: Map? = null, -) { - fun toJson() = serialFormat.encodeToString(this) +): PayReqResponse { + override fun toJson() = serialFormat.encodeToString(this) fun signablePayload(payerIdentifier: String): ByteArray { if (payeeData == null) throw IllegalArgumentException("Payee data is required for UMA") @@ -44,12 +56,25 @@ data class PayReqResponse( } } - fun isUmaResponse() = payeeData != null && + override fun isUmaResponse() = payeeData != null && payeeData.payeeCompliance() != null && payeeData.identifier() != null && paymentInfo != null } +@Serializable +data class PayReqResponseV0( + @SerialName("pr") + override val encodedInvoice: String, + 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 data class Route( val pubkey: String, @@ -83,10 +108,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..3c6d607 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,8 +12,23 @@ 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 +sealed interface PayRequest { + val amount: Long + val payerData: PayerData? + fun sendingCurrencyCode(): String? + + fun receivingCurrencyCode(): String? + + fun isUmaRequest(): Boolean + + fun signablePayload(): ByteArray +} + /** * The request sent by the sender to the receiver to retrieve an invoice. * @@ -27,16 +43,20 @@ import me.uma.utils.serialFormat * 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( +@Serializable +data class PayRequestV1( val sendingCurrencyCode: String?, val receivingCurrencyCode: String?, - val amount: Long, - val payerData: PayerData?, + override val amount: Long, + override val payerData: PayerData?, val requestedPayeeData: CounterPartyDataOptions? = null, 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,7 +65,7 @@ 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) @@ -86,7 +106,7 @@ data class PayRequest @JvmOverloads constructor( ) } val comment = queryMap["comment"]?.firstOrNull() - return PayRequest( + return PayRequestV1( sendingCurrencyCode, receivingCurrencyCode, amount, @@ -98,9 +118,27 @@ data class PayRequest @JvmOverloads constructor( } } +@Serializable +data class PayRequestV0( + @SerialName("currency") + val currencyCode: String, + override val amount: Long, + override val payerData: PayerData, +): PayRequest { + override fun receivingCurrencyCode() = currencyCode + override fun sendingCurrencyCode() = null + override fun isUmaRequest() = true + override fun signablePayload() = + payerData.compliance()?.let { + "${payerData.identifier()}|${it.signatureNonce}|${it.signatureTimestamp}".encodeToByteArray() + } ?: payerData.identifier()?.encodeToByteArray() ?: throw IllegalArgumentException("Payer identifier is required for UMA") + + fun toJson() = serialFormat.encodeToString(this) +} + @OptIn(ExperimentalSerializationApi::class) -object PayRequestSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PayRequest") { +object PayRequestV1Serializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PayRequestV1") { element("convert") element("amount") // Serialize and deserialize amount as a string element("payerData") @@ -108,7 +146,7 @@ object PayRequestSerializer : KSerializer { element("comment") } - 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 +169,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 +208,7 @@ object PayRequestSerializer : KSerializer { } } ?: throw IllegalArgumentException("Amount is required") - PayRequest( + PayRequestV1( sendingCurrencyCode, receivingCurrencyCode, parsedAmount, @@ -181,3 +219,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/utils/Serialization.kt b/uma-sdk/src/commonMain/kotlin/me/uma/utils/Serialization.kt index 5e37b60..a630cc5 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/utils/Serialization.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/utils/Serialization.kt @@ -3,10 +3,23 @@ package me.uma.utils 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, Currency.CurrencyV1::class, Currency.CurrencyV1.serializer()) - polymorphic(Currency::class, Currency.CurrencyV0::class, Currency.CurrencyV0.serializer()) + 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()) } val serialFormat = Json { From 52747c04bc90b7e295651ce92f0838560e4403a6 Mon Sep 17 00:00:00 2001 From: shreyav Date: Mon, 18 Mar 2024 17:27:33 -0700 Subject: [PATCH 03/13] clean up --- .../test/java/me/uma/javatest/UmaTest.java | 131 +++++++++++------- .../kotlin/me/uma/UmaProtocolHelper.kt | 20 +-- .../kotlin/me/uma/protocol/Currency.kt | 16 ++- .../kotlin/me/uma/protocol/PayReqResponse.kt | 68 ++++++--- .../kotlin/me/uma/protocol/PayRequest.kt | 60 +++++--- .../kotlin/me/uma/protocol/PubKeyResponse.kt | 4 +- .../src/commonTest/kotlin/me/uma/UmaTests.kt | 45 +++++- 7 files changed, 226 insertions(+), 118 deletions(-) diff --git a/javatest/src/test/java/me/uma/javatest/UmaTest.java b/javatest/src/test/java/me/uma/javatest/UmaTest.java index fe2846b..3559158 100644 --- a/javatest/src/test/java/me/uma/javatest/UmaTest.java +++ b/javatest/src/test/java/me/uma/javatest/UmaTest.java @@ -7,6 +7,7 @@ import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -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,16 +109,7 @@ public void testGetLnurlpResponse() throws Exception { ) ), List.of( -// new Currency.CurrencyV0( -// "USD", -// "US Dollar", -// "$", -// 34_150, -// 1, -// 10_000_000, -// 2 -// ) - new Currency.CurrencyV1( + new CurrencyV1( "USD", "US Dollar", "$", @@ -131,7 +123,6 @@ public void testGetLnurlpResponse() throws Exception { assertNotNull(lnurlpResponse); String responseJson = lnurlpResponse.toJson(); System.out.println(responseJson); - System.out.println(lnurlpResponse.getCurrencies()); LnurlpResponse parsedResponse = umaProtocolHelper.parseAsLnurlpResponse(responseJson); assertNotNull(parsedResponse); assertEquals(lnurlpResponse, parsedResponse); @@ -141,7 +132,7 @@ public void testGetLnurlpResponse() throws Exception { } @Test - public void testGetCurrency() throws Exception { + public void testGetLnurlpResponse_umaV0() throws Exception { String lnurlpUrl = umaProtocolHelper.getSignedLnurlpRequestUrl( privateKeyBytes(), "$bob@vasp2.com", @@ -149,52 +140,90 @@ public void testGetCurrency() throws Exception { true); LnurlpRequest request = umaProtocolHelper.parseLnurlpRequest(lnurlpUrl); assertNotNull(request); - - Currency currency = new Currency.CurrencyV1( - "USD", - "US Dollar", - "$", - 34_150, - new CurrencyConvertible(1, 10_000_000), - 2 - ); - - Currency currencyv0 = new Currency.CurrencyV0( - "USD", - "US Dollar", - "$", - 34_150, + LnurlpResponse lnurlpResponse = umaProtocolHelper.getLnurlpResponse( + request, + privateKeyBytes(), + true, + "https://vasp2.com/callback", + "encoded metadata", 1, 10_000_000, - 2 + CounterPartyData.createCounterPartyDataOptions( + Map.of( + "name", false, + "email", false, + "identity", true, + "compliance", true + ) + ), + List.of( + new CurrencyV0( + "USD", + "US Dollar", + "$", + 34_150, + 1, + 10_000_000, + 2 + ) + ), + KycStatus.VERIFIED ); - String responseJson = umaProtocolHelper.encodeAsCurrency(currencyv0); - System.out.println("hi" + responseJson); - Currency parsedResponse = umaProtocolHelper.parseAsCurrency(responseJson); + assertNotNull(lnurlpResponse); + String responseJson = lnurlpResponse.toJson(); + System.out.println(responseJson); + LnurlpResponse parsedResponse = umaProtocolHelper.parseAsLnurlpResponse(responseJson); assertNotNull(parsedResponse); - assertEquals(currencyv0, parsedResponse); + assertEquals(lnurlpResponse, parsedResponse); + assertTrue(umaProtocolHelper.verifyLnurlpResponseSignature( + parsedResponse.asUmaResponse(), new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), + new InMemoryNonceCache(1L))); } @Test - public void testGetCurrencyV0() throws Exception { - Currency currencyv0 = new Currency.CurrencyV0( + public void testGetPayReqResponseSync_umaV1() throws Exception { + PayRequest request = umaProtocolHelper.getPayRequest( + publicKeyBytes(), + privateKeyBytes(), "USD", - "US Dollar", - "$", - 34_150, - 1, - 10_000_000, - 2 + 100L, + true, + "$alice@vasp1.com", + KycStatus.VERIFIED, + "" ); - String responseJson = "{\"code\":\"USD\",\"name\":\"US Dollar\",\"symbol\":\"$\",\"multiplier\":34150.0,\"minSendable\":1,\"maxSendable\":10000000,\"decimals\":2}"; - System.out.println("hi" + responseJson); - Currency parsedResponse = umaProtocolHelper.parseAsCurrency(responseJson); + 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, + "1.0" + ); + assertNotNull(response); + assertEquals("lnbc12345", response.getEncodedInvoice()); + System.out.println(response); + assertTrue(response instanceof PayReqResponseV1); + 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(currencyv0, parsedResponse); + assertEquals(response, parsedResponse); } @Test - public void testGetPayReqResponseSync() throws Exception { + public void testGetPayReqResponseSync_umaV0() throws Exception { PayRequest request = umaProtocolHelper.getPayRequest( publicKeyBytes(), privateKeyBytes(), @@ -217,14 +246,22 @@ public void testGetPayReqResponseSync() throws Exception { null, "", privateKeyBytes(), - PayeeData.createPayeeData(null, "$bob@vasp2.com") + PayeeData.createPayeeData(null, "$bob@vasp2.com"), + null, + null, + "0.3" ); assertNotNull(response); assertEquals("lnbc12345", response.getEncodedInvoice()); System.out.println(response); + assertTrue(response instanceof PayReqResponseV0); 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 1b5e7b2..0e92686 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt @@ -273,14 +273,6 @@ class UmaProtocolHelper @JvmOverloads constructor( return serialFormat.decodeFromString(response) } - fun parseAsCurrency(response: String): Currency { - return serialFormat.decodeFromString(CurrencySerializer, response) - } - - fun encodeAsCurrency(currency: Currency): String { - return serialFormat.encodeToString(CurrencySerializer, currency) - } - /** * Verifies the signature on an UMA Lnurlp response based on the public key of the VASP making the request. * @@ -332,6 +324,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 @@ -435,10 +428,6 @@ class UmaProtocolHelper @JvmOverloads constructor( return serialFormat.decodeFromString(PayRequestSerializer, request) } - fun encodePayReq(payReq: PayRequest): String { - return serialFormat.encodeToString(PayRequestSerializer, payReq) - } - /** * Verifies the signature of the [PayRequest] sent by the sender. * @@ -494,6 +483,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 @@ -565,6 +555,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 @@ -638,6 +629,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") @@ -769,10 +761,6 @@ class UmaProtocolHelper @JvmOverloads constructor( return serialFormat.decodeFromString(PayReqResponseSerializer, response) } - fun encodePayReqResponse(payReqResponse: PayReqResponse): String { - return serialFormat.encodeToString(PayReqResponseSerializer, payReqResponse) - } - /** * Verifies the signature of the [PayReqResponse] sent by the receiver. * 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 f7ca00f..1666682 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt @@ -40,7 +40,6 @@ sealed interface Currency { val decimals: Int } - @Serializable data class CurrencyV1( override val code: String, @@ -48,10 +47,13 @@ data class CurrencyV1( 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. + * 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 @@ -62,15 +64,15 @@ data class CurrencyV0( 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). + * 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). + * 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, 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 8998efc..92a5286 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt @@ -6,45 +6,68 @@ 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. + */ sealed interface PayReqResponse { + /** + * The BOLT11 invoice that the sender will pay. + */ val encodedInvoice: String - val routes: List + + /** + * 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 } -/** - * 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. - */ @OptIn(ExperimentalSerializationApi::class) @Serializable data class PayReqResponseV1( @SerialName("pr") 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 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, ): 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") if (payeeData.identifier() == null) throw IllegalArgumentException("Payee identifier is required for UMA") @@ -55,23 +78,24 @@ data class PayReqResponseV1( .encodeToByteArray() } } - - override fun isUmaResponse() = payeeData != null && - payeeData.payeeCompliance() != null && - payeeData.identifier() != null && - paymentInfo != null } @Serializable 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) } 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 3c6d607..f8a14fe 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt @@ -17,43 +17,61 @@ 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. + */ 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 } -/** - * 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 data class PayRequestV1( val sendingCurrencyCode: String?, val receivingCurrencyCode: String?, 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, ) : PayRequest { override fun receivingCurrencyCode() = receivingCurrencyCode + override fun sendingCurrencyCode() = sendingCurrencyCode override fun signablePayload(): ByteArray { @@ -67,7 +85,7 @@ data class PayRequestV1( override fun isUmaRequest() = payerData != null && payerData.compliance() != null && payerData.identifier() != null - fun toJson() = serialFormat.encodeToString(this) + override fun toJson() = serialFormat.encodeToString(this) fun toQueryParamMap(): Map> { val amountStr = if (sendingCurrencyCode != null) { @@ -120,20 +138,28 @@ data class PayRequestV1( @Serializable data class PayRequestV0( + /** + * The currency code that the receiver will receive for this payment. + */ @SerialName("currency") val currencyCode: String, + override val amount: Long, override val payerData: PayerData, ): PayRequest { override fun receivingCurrencyCode() = currencyCode + override fun sendingCurrencyCode() = null + override fun isUmaRequest() = true + override fun signablePayload() = payerData.compliance()?.let { "${payerData.identifier()}|${it.signatureNonce}|${it.signatureTimestamp}".encodeToByteArray() - } ?: payerData.identifier()?.encodeToByteArray() ?: throw IllegalArgumentException("Payer identifier is required for UMA") + } ?: payerData.identifier()?.encodeToByteArray() + ?: throw IllegalArgumentException("Payer identifier is required for UMA") - fun toJson() = serialFormat.encodeToString(this) + override fun toJson() = serialFormat.encodeToString(this) } @OptIn(ExperimentalSerializationApi::class) 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 cc0b955..f9c0ae0 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt @@ -14,8 +14,8 @@ import me.uma.utils.serialFormat * * @property signingCertChain list of X.509 certificates. The order of the certificates is from the * leaf to the root. Used to verify signatures from the VASP. - * @property encryptionCertChain list of X.509 certificates. The order of the certificates is from - * the leaf to the root. Used to encrypt TR info sent to the VASP. + * @property encryptionCertChain list of X.509 certificates. The order of the certificates is from the + * leaf to the root. Used to encrypt TR info sent to the VASP. * @property signingPubKey The public key used to verify signatures from the VASP. * @property encryptionPubKey The public key used to encrypt TR info sent to the VASP. * @property expirationTimestamp Seconds since epoch at which these pub keys must be refreshed. diff --git a/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt b/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt index e1f8e22..8e8ac51 100644 --- a/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt +++ b/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt @@ -1,19 +1,23 @@ package me.uma -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -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 import me.uma.utils.serialFormat +import org.junit.jupiter.api.Assertions.assertTrue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.fail @OptIn(ExperimentalCoroutinesApi::class) class UmaTests { @@ -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) + } } From 70532d10b2bc07e760628c314453c4c8e44b5232 Mon Sep 17 00:00:00 2001 From: shreyav Date: Tue, 19 Mar 2024 10:56:40 -0700 Subject: [PATCH 04/13] lint --- .../src/commonMain/kotlin/me/uma/protocol/Currency.kt | 2 ++ .../commonMain/kotlin/me/uma/protocol/PayRequest.kt | 2 +- uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt | 10 +++++----- 3 files changed, 8 insertions(+), 6 deletions(-) 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 1666682..7a80f33 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt @@ -70,11 +70,13 @@ data class CurrencyV0( * 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 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 f8a14fe..6c0fe3c 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt @@ -85,7 +85,7 @@ data class PayRequestV1( override fun isUmaRequest() = payerData != null && payerData.compliance() != null && payerData.identifier() != null - override fun toJson() = serialFormat.encodeToString(this) + override fun toJson() = serialFormat.encodeToString(PayRequestV1Serializer, this) fun toQueryParamMap(): Map> { val amountStr = if (sendingCurrencyCode != null) { diff --git a/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt b/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt index 8e8ac51..af27215 100644 --- a/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt +++ b/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt @@ -1,5 +1,10 @@ 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 @@ -13,11 +18,6 @@ import me.uma.protocol.TravelRuleFormat import me.uma.protocol.compliance import me.uma.protocol.createCounterPartyDataOptions import me.uma.utils.serialFormat -import org.junit.jupiter.api.Assertions.assertTrue -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.fail @OptIn(ExperimentalCoroutinesApi::class) class UmaTests { From 6e824174213a8a0f66dae6578a0789c1a2677692 Mon Sep 17 00:00:00 2001 From: shreyav Date: Tue, 19 Mar 2024 11:02:34 -0700 Subject: [PATCH 05/13] lint --- uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt | 8 ++++++-- .../commonMain/kotlin/me/uma/protocol/PayReqResponse.kt | 4 ++-- .../src/commonMain/kotlin/me/uma/protocol/PayRequest.kt | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt index 0e92686..90eccb6 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt @@ -231,9 +231,13 @@ class UmaProtocolHelper @JvmOverloads constructor( maxSendable = it.convertible.max, decimals = it.decimals, ) - } else it + } else { + it + } } - } else currencyOptions + } else { + currencyOptions + } return LnurlpResponse( callback = callback, minSendable = minSendableSats * 1000, 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 92a5286..cc8b337 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt @@ -60,7 +60,7 @@ data class PayReqResponseV1( * Defines a struct which can be stored and shown to the user on payment success. See LUD-09. */ val successAction: Map? = null, -): PayReqResponse { +) : PayReqResponse { override fun toJson() = serialFormat.encodeToString(this) override fun isUmaResponse() = payeeData != null && @@ -93,7 +93,7 @@ data class PayReqResponseV0( override val paymentInfo: PayReqResponsePaymentInfo, @EncodeDefault override val routes: List = emptyList(), -): PayReqResponse { +) : PayReqResponse { override fun isUmaResponse() = true override fun toJson() = serialFormat.encodeToString(this) 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 6c0fe3c..1fa46ff 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt @@ -146,7 +146,7 @@ data class PayRequestV0( override val amount: Long, override val payerData: PayerData, -): PayRequest { +) : PayRequest { override fun receivingCurrencyCode() = currencyCode override fun sendingCurrencyCode() = null @@ -157,7 +157,7 @@ data class PayRequestV0( payerData.compliance()?.let { "${payerData.identifier()}|${it.signatureNonce}|${it.signatureTimestamp}".encodeToByteArray() } ?: payerData.identifier()?.encodeToByteArray() - ?: throw IllegalArgumentException("Payer identifier is required for UMA") + ?: throw IllegalArgumentException("Payer identifier is required for UMA") override fun toJson() = serialFormat.encodeToString(this) } From 54eb28dc3c6bac671fbc85f241fd4e00335ac285 Mon Sep 17 00:00:00 2001 From: shreyav Date: Tue, 19 Mar 2024 12:25:17 -0700 Subject: [PATCH 06/13] currency util --- .../test/java/me/uma/javatest/UmaTest.java | 1 - .../kotlin/me/uma/UmaProtocolHelper.kt | 52 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/javatest/src/test/java/me/uma/javatest/UmaTest.java b/javatest/src/test/java/me/uma/javatest/UmaTest.java index 3559158..7926c3a 100644 --- a/javatest/src/test/java/me/uma/javatest/UmaTest.java +++ b/javatest/src/test/java/me/uma/javatest/UmaTest.java @@ -7,7 +7,6 @@ import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.Test; -import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import java.util.Map; diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt index 90eccb6..f30704b 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt @@ -846,6 +846,58 @@ class UmaProtocolHelper @JvmOverloads constructor( return serialFormat.decodeFromString(callback) } + /** + * 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 getCurrency( + 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, + ) + } + } + @Throws(Exception::class) private fun signPayload(payload: ByteArray, privateKey: ByteArray): String { return Secp256k1.signEcdsa(payload, privateKey).toHexString() From bf824bec099086e3b195e78fe555a817b4e0bab4 Mon Sep 17 00:00:00 2001 From: shreyav Date: Tue, 19 Mar 2024 23:17:07 -0700 Subject: [PATCH 07/13] more payreq utils --- .../kotlin/me/uma/protocol/PayRequest.kt | 76 +++++++++++-------- 1 file changed, 45 insertions(+), 31 deletions(-) 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 1fa46ff..0cce799 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt @@ -48,6 +48,40 @@ sealed interface PayRequest { 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 @@ -87,7 +121,9 @@ data class PayRequestV1( 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 { @@ -104,36 +140,6 @@ data class PayRequestV1( comment?.let { map["comment"] = listOf(it) } return 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 @@ -153,6 +159,8 @@ data class PayRequestV0( override fun isUmaRequest() = true + override fun requestedPayeeData() = null + override fun signablePayload() = payerData.compliance()?.let { "${payerData.identifier()}|${it.signatureNonce}|${it.signatureTimestamp}".encodeToByteArray() @@ -160,6 +168,12 @@ data class PayRequestV0( ?: 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) From 88ec9849f86276140344968523fad16295eba5d9 Mon Sep 17 00:00:00 2001 From: shreyav Date: Tue, 19 Mar 2024 23:23:22 -0700 Subject: [PATCH 08/13] more tests --- .../test/java/me/uma/javatest/UmaTest.java | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/javatest/src/test/java/me/uma/javatest/UmaTest.java b/javatest/src/test/java/me/uma/javatest/UmaTest.java index 7926c3a..c47edc3 100644 --- a/javatest/src/test/java/me/uma/javatest/UmaTest.java +++ b/javatest/src/test/java/me/uma/javatest/UmaTest.java @@ -179,6 +179,72 @@ public void testGetLnurlpResponse_umaV0() throws Exception { 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(request instanceof PayRequestV1); + 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(request instanceof PayRequestV0); + 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( From 4879f5e796483db0e3fb828ba96cef47423708d6 Mon Sep 17 00:00:00 2001 From: shreyav Date: Tue, 19 Mar 2024 23:29:08 -0700 Subject: [PATCH 09/13] lint --- uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0cce799..8d13a6c 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt @@ -172,7 +172,7 @@ data class PayRequestV0( override fun toQueryParamMap() = mapOf( "amount" to listOf(amount.toString()), "convert" to listOf(currencyCode), - "payerData" to listOf(serialFormat.encodeToString(payerData)) + "payerData" to listOf(serialFormat.encodeToString(payerData)), ) } From cd5deb087e1bfca630745431be21fa846469d065 Mon Sep 17 00:00:00 2001 From: shreyav Date: Wed, 20 Mar 2024 17:14:50 -0700 Subject: [PATCH 10/13] make all version specific objects internal --- .../kotlin/me/uma/UmaProtocolHelper.kt | 52 ----------------- .../kotlin/me/uma/protocol/Currency.kt | 58 ++++++++++++++++++- .../kotlin/me/uma/protocol/PayReqResponse.kt | 4 +- .../kotlin/me/uma/protocol/PayRequest.kt | 6 +- .../kotlin/me/uma/protocol/PubKeyResponse.kt | 4 +- 5 files changed, 63 insertions(+), 61 deletions(-) diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt index f30704b..90eccb6 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt @@ -846,58 +846,6 @@ class UmaProtocolHelper @JvmOverloads constructor( return serialFormat.decodeFromString(callback) } - /** - * 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 getCurrency( - 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, - ) - } - } - @Throws(Exception::class) private fun signPayload(payload: ByteArray, privateKey: ByteArray): String { return Secp256k1.signEcdsa(payload, privateKey).toHexString() 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 7a80f33..6e805ec 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt @@ -5,6 +5,8 @@ import kotlinx.serialization.Serializable 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 sealed interface Currency { /** @@ -40,8 +42,60 @@ sealed interface Currency { val decimals: Int } +/** + * 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 getCurrency( + 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 -data class CurrencyV1( +internal data class CurrencyV1( override val code: String, override val name: String, override val symbol: String, @@ -58,7 +112,7 @@ data class CurrencyV1( ) : Currency @Serializable -data class CurrencyV0( +internal data class CurrencyV0( override val code: String, override val name: String, override val symbol: String, 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 cc8b337..59b6f4e 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt @@ -34,7 +34,7 @@ sealed interface PayReqResponse { @OptIn(ExperimentalSerializationApi::class) @Serializable -data class PayReqResponseV1( +internal data class PayReqResponseV1( @SerialName("pr") override val encodedInvoice: String, override val paymentInfo: PayReqResponsePaymentInfo?, @@ -81,7 +81,7 @@ data class PayReqResponseV1( } @Serializable -data class PayReqResponseV0( +internal data class PayReqResponseV0( @SerialName("pr") override val encodedInvoice: String, 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 8d13a6c..fc6b63c 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt @@ -85,7 +85,7 @@ sealed interface PayRequest { } @Serializable -data class PayRequestV1( +internal data class PayRequestV1( val sendingCurrencyCode: String?, val receivingCurrencyCode: String?, override val amount: Long, @@ -143,7 +143,7 @@ data class PayRequestV1( } @Serializable -data class PayRequestV0( +internal data class PayRequestV0( /** * The currency code that the receiver will receive for this payment. */ @@ -177,7 +177,7 @@ data class PayRequestV0( } @OptIn(ExperimentalSerializationApi::class) -object PayRequestV1Serializer : KSerializer { +internal object PayRequestV1Serializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PayRequestV1") { element("convert") element("amount") // Serialize and deserialize amount as a string 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 f9c0ae0..dd39958 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt @@ -26,9 +26,9 @@ data class PubKeyResponse internal constructor( 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 From 1cc16934e5e61f77046208d5c8edb98a2b55cdbb Mon Sep 17 00:00:00 2001 From: shreyav Date: Thu, 21 Mar 2024 11:49:56 -0700 Subject: [PATCH 11/13] expose min/max from interface --- .../test/java/me/uma/javatest/UmaTest.java | 24 ++++++++++------- .../kotlin/me/uma/protocol/Currency.kt | 26 ++++++++++++++++--- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/javatest/src/test/java/me/uma/javatest/UmaTest.java b/javatest/src/test/java/me/uma/javatest/UmaTest.java index c47edc3..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()); @@ -108,13 +109,15 @@ public void testGetLnurlpResponse_umaV1() throws Exception { ) ), List.of( - new CurrencyV1( + createCurrency( "USD", "US Dollar", "$", 34_150, - new CurrencyConvertible(1, 10_000_000), - 2 + 2, + 1, + 10_000_000, + "1.0" ) ), KycStatus.VERIFIED @@ -125,6 +128,9 @@ public void testGetLnurlpResponse_umaV1() 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))); @@ -156,14 +162,15 @@ public void testGetLnurlpResponse_umaV0() throws Exception { ) ), List.of( - new CurrencyV0( + createCurrency( "USD", "US Dollar", "$", 34_150, + 2, 1, 10_000_000, - 2 + "0.3" ) ), KycStatus.VERIFIED @@ -174,6 +181,9 @@ public void testGetLnurlpResponse_umaV0() 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( parsedResponse.asUmaResponse(), new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), new InMemoryNonceCache(1L))); @@ -202,7 +212,6 @@ public void testGetPayRequest_umaV1() throws Exception { ); assertNotNull(request); System.out.println(request); - assertTrue(request instanceof PayRequestV1); assertTrue(umaProtocolHelper.verifyPayReqSignature( request, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), new InMemoryNonceCache(1L))); @@ -235,7 +244,6 @@ public void testGetPayRequest_umaV0() throws Exception { ); assertNotNull(request); System.out.println(request); - assertTrue(request instanceof PayRequestV0); assertTrue(umaProtocolHelper.verifyPayReqSignature( request, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), new InMemoryNonceCache(1L))); @@ -277,7 +285,6 @@ public void testGetPayReqResponseSync_umaV1() throws Exception { assertNotNull(response); assertEquals("lnbc12345", response.getEncodedInvoice()); System.out.println(response); - assertTrue(response instanceof PayReqResponseV1); assertTrue(umaProtocolHelper.verifyPayReqResponseSignature( response, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), "$alice@vasp1.com", new InMemoryNonceCache(1L))); @@ -319,7 +326,6 @@ public void testGetPayReqResponseSync_umaV0() throws Exception { assertNotNull(response); assertEquals("lnbc12345", response.getEncodedInvoice()); System.out.println(response); - assertTrue(response instanceof PayReqResponseV0); assertTrue(umaProtocolHelper.verifyPayReqResponseSignature( response, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), "$alice@vasp1.com", new InMemoryNonceCache(1L))); 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 6e805ec..523860e 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt @@ -1,3 +1,5 @@ +@file:JvmName("CurrencyUtils") + package me.uma.protocol import kotlinx.serialization.SerialName @@ -40,6 +42,10 @@ sealed interface 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 minSendable(): Long + + fun maxSendable(): Long } /** @@ -59,7 +65,7 @@ sealed interface Currency { * @return the [Currency] to be sent to the sender VASP. */ @JvmOverloads -fun getCurrency( +fun createCurrency( code: String, name: String, symbol: String, @@ -109,7 +115,13 @@ internal data class CurrencyV1( val convertible: CurrencyConvertible, override val decimals: Int, -) : Currency +) : Currency { + + override fun minSendable() = convertible.min + + override fun maxSendable() = convertible.max + +} @Serializable internal data class CurrencyV0( @@ -132,13 +144,19 @@ internal data class CurrencyV0( val maxSendable: Long, override val decimals: Int, -) : Currency +) : 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). From 9a6241bab158b3db2b68746e963837f7188ea309 Mon Sep 17 00:00:00 2001 From: shreyav Date: Thu, 21 Mar 2024 13:37:36 -0700 Subject: [PATCH 12/13] lint --- uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt | 2 -- 1 file changed, 2 deletions(-) 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 523860e..f1395dd 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt @@ -120,7 +120,6 @@ internal data class CurrencyV1( override fun minSendable() = convertible.min override fun maxSendable() = convertible.max - } @Serializable @@ -149,7 +148,6 @@ internal data class CurrencyV0( override fun minSendable() = minSendable override fun maxSendable() = maxSendable - } /** From 36e8e98cfcf113ca091928d5d14bba1ad8d0b395 Mon Sep 17 00:00:00 2001 From: shreyav Date: Thu, 21 Mar 2024 14:31:07 -0700 Subject: [PATCH 13/13] make some descriptor properties optional --- uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 fc6b63c..512fd8c 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt @@ -179,11 +179,11 @@ internal data class PayRequestV0( @OptIn(ExperimentalSerializationApi::class) internal object PayRequestV1Serializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PayRequestV1") { - element("convert") + 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: PayRequestV1) {