Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate to the new LUD-21 spec for currency exchange #25

Merged
merged 3 commits into from
Mar 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-and-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
branches: [ "main", "release/v1.0" ]

jobs:
build:
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ You can install the SDK from Maven Central using Gradle or Maven.

```groovy
dependencies {
implementation 'me.uma:uma-sdk:0.7.5'
implementation 'me.uma:uma-sdk:1.0.0'
}
```

**In Kotlin:**

```kotlin
dependencies {
implementation("me.uma:uma-sdk:0.7.5")
implementation("me.uma:uma-sdk:1.0.0")
}
```

Expand All @@ -40,7 +40,7 @@ dependencies {
<dependency>
<groupId>me.uma</groupId>
<artifactId>uma-sdk</artifactId>
<version>0.7.5</version>
<version>1.0.0</version>
</dependency>
</dependencies>
```
18 changes: 4 additions & 14 deletions javatest/src/test/java/me/uma/javatest/UmaTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import me.uma.protocol.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
Expand All @@ -22,18 +23,6 @@
import me.uma.UmaInvoiceCreator;
import me.uma.UmaProtocolHelper;
import me.uma.UmaRequester;
import me.uma.protocol.CounterPartyData;
import me.uma.protocol.Currency;
import me.uma.protocol.KycStatus;
import me.uma.protocol.LnurlpRequest;
import me.uma.protocol.LnurlpResponse;
import me.uma.protocol.PayReqResponse;
import me.uma.protocol.PayRequest;
import me.uma.protocol.PayeeData;
import me.uma.protocol.PayerData;
import me.uma.protocol.PostTransactionCallback;
import me.uma.protocol.PubKeyResponse;
import me.uma.protocol.UtxoWithAmount;

public class UmaTest {
UmaProtocolHelper umaProtocolHelper = new UmaProtocolHelper(new InMemoryPublicKeyCache(), new TestUmaRequester());
Expand Down Expand Up @@ -105,8 +94,7 @@ public void testGetLnurlpResponse() throws Exception {
"US Dollar",
"$",
34_150,
1,
10_000_000,
new CurrencyConvertible(1, 10_000_000),
2
)
),
Expand All @@ -126,6 +114,7 @@ parsedResponse, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()),
@Test
public void testGetPayReqResponseSync() throws Exception {
PayRequest request = new PayRequest(
"USD",
"USD",
100,
PayerData.createPayerData("[email protected]"),
Expand Down Expand Up @@ -163,6 +152,7 @@ response, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()),
@Test
public void testGetPayReqResponseFuture() throws Exception {
PayRequest request = new PayRequest(
"USD",
"USD",
100,
PayerData.createPayerData("[email protected]")
Expand Down
2 changes: 1 addition & 1 deletion uma-sdk/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
GROUP=me.uma
POM_ARTIFACT_ID=uma-sdk
# Don't bump this manually. Run `scripts/versions.main.kt <new_version>` to bump the version instead.
VERSION_NAME=0.7.5
VERSION_NAME=1.0.0

POM_DESCRIPTION=The UMA SDK for Kotlin and Java.
POM_INCEPTION_YEAR=2023
Expand Down
31 changes: 26 additions & 5 deletions uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,11 @@ class UmaProtocolHelper @JvmOverloads constructor(
* information.
* @param sendingVaspPrivateKey The private key of the VASP that is sending the payment. This will be used to sign
* the request.
* @param currencyCode The code of the currency that the receiver will receive for this payment.
* @param amount The amount of the payment in the smallest unit of the specified currency (i.e. cents for USD).
* @param receivingCurrencyCode The code of the currency that the receiver will receive for this payment.
* @param amount The amount that the receiver will receive in either the smallest unit of the receiving currency
* (if `isAmountInReceivingCurrency` is True), or in msats (if false).
* @param isAmountInReceivingCurrency Whether the amount field is specified in the smallest unit of the receiving
* currency or in msats (if false).
* @param payerIdentifier The identifier of the sender. For example, [email protected]
* @param payerKycStatus Indicates whether VASP1 has KYC information about the sender.
* @param utxoCallback The URL that the receiver will call to send UTXOs of the channel that the receiver used to
Expand All @@ -278,8 +281,9 @@ class UmaProtocolHelper @JvmOverloads constructor(
fun getPayRequest(
receiverEncryptionPubKey: ByteArray,
sendingVaspPrivateKey: ByteArray,
currencyCode: String,
receivingCurrencyCode: String,
amount: Long,
isAmountInReceivingCurrency: Boolean,
payerIdentifier: String,
payerKycStatus: KycStatus,
utxoCallback: String,
Expand Down Expand Up @@ -309,8 +313,9 @@ class UmaProtocolHelper @JvmOverloads constructor(
compliance = compliancePayerData,
)
return PayRequest(
sendingCurrencyCode = if (isAmountInReceivingCurrency) receivingCurrencyCode else null,
payerData = payerData,
currencyCode = currencyCode,
receivingCurrencyCode = receivingCurrencyCode,
amount = amount,
requestedPayeeData = requestedPayeeData,
)
Expand Down Expand Up @@ -552,8 +557,23 @@ class UmaProtocolHelper @JvmOverloads constructor(
): PayReqResponse {
val encodedPayerData = Json.encodeToString(query.payerData)
val metadataWithPayerData = "$metadata$encodedPayerData"
if (query.sendingCurrencyCode != null && query.sendingCurrencyCode != currencyCode) {
throw IllegalArgumentException(
"Currency code in the pay request must match the receiving currency if not null.",
)
}
val isAmountInMsats = query.sendingCurrencyCode == null
val receivingCurrencyAmount = if (isAmountInMsats) {
((query.amount.toDouble() - receiverFeesMillisats) / conversionRate).roundToLong()
} else {
query.amount
}
val invoice = invoiceCreator.createUmaInvoice(
amountMsats = (query.amount.toDouble() * conversionRate + receiverFeesMillisats).roundToLong(),
amountMsats = if (isAmountInMsats) {
query.amount
} else {
(query.amount.toDouble() * conversionRate + receiverFeesMillisats).roundToLong()
},
metadata = metadataWithPayerData,
).await()
val mutablePayeeData = payeeData?.toMutableMap() ?: mutableMapOf()
Expand All @@ -571,6 +591,7 @@ class UmaProtocolHelper @JvmOverloads constructor(
encodedInvoice = invoice,
payeeData = JsonObject(mutablePayeeData),
paymentInfo = PayReqResponsePaymentInfo(
amount = receivingCurrencyAmount,
currencyCode = currencyCode,
decimals = currencyDecimals,
multiplier = conversionRate,
Expand Down
28 changes: 19 additions & 9 deletions uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,9 @@ data class Currency(
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).
* The minimum and maximum amounts that can be sent in this currency and converted from SATs by the receiver.
*/
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,
val convertible: CurrencyConvertible,

/**
* The number of digits after the decimal point for display on the sender side, and to add clarity
Expand All @@ -54,3 +47,20 @@ data class Currency(
) {
fun toJson() = Json.encodeToString(this)
}

/**
* The `convertible` field of the [Currency] object.
*/
@Serializable
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).
*/
val min: Long,
/**
* Maximum amount that can be sent in this currency. This is in the smallest unit of the currency
* (eg. cents for USD).
*/
val max: Long,
)
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ data class RouteHop(
/**
* The payment info from the receiver.
*
* @property amount The amount that the receiver will receive in the smallest unit of the specified currency.
* @property currencyCode The currency code that the receiver will receive for this payment.
* @property decimals Number of digits after the decimal point for the receiving currency. For example, in USD, by
* convention, there are 2 digits for cents - $5.95. In this case, `decimals` would be 2. This should align with
Expand All @@ -70,8 +71,10 @@ data class RouteHop(
*/
@Serializable
data class PayReqResponsePaymentInfo(
val amount: Long,
val currencyCode: String,
val decimals: Int,
val multiplier: Double,
@SerialName("fee")
val exchangeFeesMillisatoshi: Long,
)
105 changes: 95 additions & 10 deletions uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
package me.uma.protocol

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.*
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encoding.*
import kotlinx.serialization.json.Json

/**
* The request sent by the sender to the receiver to retrieve an invoice.
*
* @property currencyCode The currency code that the receiver will receive for this payment.
* @property amount The amount of the specified currency that the receiver will receive for this payment in the smallest
* unit of the specified currency (i.e. cents for USD).
* @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.
*/
@Serializable
@Serializable(with = PayRequestSerializer::class)
data class PayRequest @JvmOverloads constructor(
@SerialName("currency")
val currencyCode: String,
val sendingCurrencyCode: String?,
val receivingCurrencyCode: String,
val amount: Long,
val payerData: PayerData,
@SerialName("payeeData")
val requestedPayeeData: CounterPartyDataOptions? = null,
) {
fun signablePayload(): ByteArray {
Expand All @@ -31,3 +38,81 @@ data class PayRequest @JvmOverloads constructor(

fun toJson() = Json.encodeToString(this)
}

@OptIn(ExperimentalSerializationApi::class)
object PayRequestSerializer : KSerializer<PayRequest> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PayRequest") {
element<String>("convert")
element<String>("amount") // Serialize and deserialize amount as a string
element<PayerData>("payerData")
element<CounterPartyDataOptions?>("payeeData")
}

override fun serialize(encoder: Encoder, value: PayRequest) {
encoder.encodeStructure(descriptor) {
encodeStringElement(descriptor, 0, value.receivingCurrencyCode)
encodeStringElement(
descriptor,
1,
if (value.sendingCurrencyCode != null) {
"${value.amount}.${value.sendingCurrencyCode}"
} else {
value.amount.toString()
},
)
encodeSerializableElement(descriptor, 2, PayerData.serializer(), value.payerData)
encodeNullableSerializableElement(
descriptor,
3,
MapSerializer(String.serializer(), CounterPartyDataOption.serializer()),
value.requestedPayeeData,
)
}
}

override fun deserialize(decoder: Decoder): PayRequest {
var sendingCurrencyCode: String? = null
var receivingCurrencyCode: String? = null
var amount: String? = null
var payerData: PayerData? = null
var requestedPayeeData: CounterPartyDataOptions? = null

return decoder.decodeStructure(descriptor) {
while (true) {
val index = decodeElementIndex(descriptor)
if (index == CompositeDecoder.DECODE_DONE) break
when (index) {
0 -> receivingCurrencyCode = decodeStringElement(descriptor, index)
1 -> amount = decodeStringElement(descriptor, index)
2 -> payerData = decodeSerializableElement(descriptor, index, PayerData.serializer())
3 -> requestedPayeeData = decodeNullableSerializableElement(
descriptor,
index,
MapSerializer(
String.serializer(),
CounterPartyDataOption.serializer(),
).nullable,
)
}
}

val parsedAmount = amount?.let {
val parts = it.split(".")
if (parts.size == 2) {
sendingCurrencyCode = parts[1]
parts[0].toLong()
} else {
it.toLong()
}
} ?: throw IllegalArgumentException("Amount is required")

PayRequest(
sendingCurrencyCode,
requireNotNull(receivingCurrencyCode) { "Receiving Currency code is required." },
parsedAmount,
requireNotNull(payerData) { "Payer data is required." },
requestedPayeeData,
)
}
}
}
Loading
Loading