diff --git a/.editorconfig b/.editorconfig index 2fa603e..601f528 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,4 +10,8 @@ ktlint_function_signature_body_expression_wrapping = default ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = unset ktlint_ignore_back_ticked_identifier = false ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_multiline-expression-wrapping = disabled +ktlint_standard_string-template-indent = disabled +ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_annotation = disabled max_line_length = 120 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 34ec796..01d7103 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,8 @@ import com.vanniktech.maven.publish.MavenPublishBaseExtension import com.vanniktech.maven.publish.SonatypeHost -import java.net.URL import org.jetbrains.dokka.gradle.DokkaMultiModuleTask import org.jetbrains.dokka.gradle.DokkaTaskPartial +import java.net.URL buildscript { dependencies { @@ -28,8 +28,9 @@ subprojects { apply(plugin = "org.jlleitschuh.gradle.ktlint") configure { outputToConsole.set(true) + debug.set(true) verbose.set(true) - disabledRules.set(listOf("no-wildcard-imports")) + disabledRules.set(setOf("no-wildcard-imports")) } tasks.create("bumpAndTagVersion") { @@ -109,16 +110,17 @@ tasks.named("dokkaHtmlMultiModule") { moduleName.set("UMA Kotlin+Java SDKs") pluginsMapConfiguration.set( mapOf( - "org.jetbrains.dokka.base.DokkaBase" to """ - { - "customStyleSheets": [ - "${rootDir.resolve("docs/css/logo-styles.css")}" - ], - "customAssets" : [ - "${rootDir.resolve("docs/images/uma-logo-white.svg")}" - ] - } - """.trimIndent(), + "org.jetbrains.dokka.base.DokkaBase" to + """ + { + "customStyleSheets": [ + "${rootDir.resolve("docs/css/logo-styles.css")}" + ], + "customAssets" : [ + "${rootDir.resolve("docs/images/uma-logo-white.svg")}" + ] + } + """.trimIndent(), ), ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dd6f088..6268953 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,12 +6,14 @@ kotlin = "1.9.0" kotlinCoroutines = "1.6.4" kotlinxDateTime = "0.4.0" kotlinSerializationJson = "1.4.1" -ktlint = "11.3.1" +kotlinReflect = "2.0.0" +ktlint = "12.1.1" ktor = "2.2.3" mavenPublish = "0.25.2" mockitoCore = "5.5.0" taskTree = "2.1.1" junit = "4.13.2" +bitcoinj-core = "0.16.3" [libraries] gradleClasspath-dokka = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } diff --git a/uma-sdk/build.gradle.kts b/uma-sdk/build.gradle.kts index 08f372e..376b21d 100644 --- a/uma-sdk/build.gradle.kts +++ b/uma-sdk/build.gradle.kts @@ -27,6 +27,7 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.ktor.client.core) implementation(libs.jna) + implementation(kotlin("reflect")) } } val commonTest by getting { diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt index 2c8522a..d705c5c 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt @@ -2,6 +2,10 @@ package me.uma +import me.uma.crypto.Secp256k1 +import me.uma.protocol.* +import me.uma.utils.isDomainLocalhost +import me.uma.utils.serialFormat import java.util.concurrent.CompletableFuture import java.util.concurrent.Future import kotlin.math.roundToLong @@ -18,10 +22,6 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.encodeToJsonElement -import me.uma.crypto.Secp256k1 -import me.uma.protocol.* -import me.uma.utils.isDomainLocalhost -import me.uma.utils.serialFormat /** * A helper class for interacting with the UMA protocol. It provides methods for creating and verifying UMA requests @@ -406,7 +406,12 @@ class UmaProtocolHelper @JvmOverloads constructor( val unsignedCompliancePayerData = CompliancePayerData( utxos = payerUtxos ?: emptyList(), nodePubKey = payerNodePubKey, - encryptedTravelRuleInfo = travelRuleInfo?.let { encryptTravelRuleInfo(receiverEncryptionPubKey, it) }, + encryptedTravelRuleInfo = travelRuleInfo?.let { + encryptTravelRuleInfo( + receiverEncryptionPubKey, + it, + ) + }, kycStatus = payerKycStatus, signature = "", signatureNonce = nonce, @@ -420,7 +425,10 @@ class UmaProtocolHelper @JvmOverloads constructor( } private fun encryptTravelRuleInfo(receiverEncryptionPubKey: ByteArray, travelRuleInfoJson: String): String { - return Secp256k1.encryptEcies(travelRuleInfoJson.encodeToByteArray(), receiverEncryptionPubKey).toHexString() + return Secp256k1.encryptEcies( + travelRuleInfoJson.encodeToByteArray(), + receiverEncryptionPubKey, + ).toHexString() } /** @@ -446,11 +454,7 @@ class UmaProtocolHelper @JvmOverloads constructor( * @throws InvalidNonceException if the nonce has already been used/timestamp is too old. */ @Throws(InvalidNonceException::class) - fun verifyPayReqSignature( - payReq: PayRequest, - pubKeyResponse: PubKeyResponse, - nonceCache: NonceCache, - ): Boolean { + fun verifyPayReqSignature(payReq: PayRequest, pubKeyResponse: PubKeyResponse, nonceCache: NonceCache): Boolean { if (!payReq.isUmaRequest()) return false val compliance = payReq.payerData?.compliance() ?: return false nonceCache.checkAndSaveNonce(compliance.signatureNonce, compliance.signatureTimestamp) @@ -715,9 +719,8 @@ class UmaProtocolHelper @JvmOverloads constructor( ), ) } - val hasPaymentInfo = receivingCurrencyCode != null && - receivingCurrencyDecimals != null && - conversionRate != null + val hasPaymentInfo = + receivingCurrencyCode != null && receivingCurrencyDecimals != null && conversionRate != null if (Version.parse(senderUmaVersion).major < 1) { if (!hasPaymentInfo) { throw IllegalArgumentException("Payment info is required for UMAv0") @@ -852,7 +855,10 @@ class UmaProtocolHelper @JvmOverloads constructor( pubKeyResponse: PubKeyResponse, nonceCache: NonceCache, ): Boolean { - nonceCache.checkAndSaveNonce(postTransactionCallback.signatureNonce, postTransactionCallback.signatureTimestamp) + nonceCache.checkAndSaveNonce( + postTransactionCallback.signatureNonce, + postTransactionCallback.signatureTimestamp, + ) return verifySignature( postTransactionCallback.signablePayload(), postTransactionCallback.signature, @@ -881,6 +887,45 @@ class UmaProtocolHelper @JvmOverloads constructor( } return identifier.substring(atIndex + 1) } + + /** + * Create an UMA invoice object + */ + fun getInvoice( + receiverUma: String, + invoiceUUID: String, + amount: Int, + receivingCurrency: InvoiceCurrency, + expiration: Int, + isSubjectToTravelRule: Boolean, + umaVersion: String, + commentCharsAllowed: Int? = null, + senderUma: String? = null, + invoiceLimit: Int? = null, + callback: String, + privateSigningKey: ByteArray, + kycStatus: KycStatus? = null, + requiredPayerData: CounterPartyDataOptions? = null, + ): Invoice { + return Invoice( + receiverUma = receiverUma, + invoiceUUID = invoiceUUID, + amount = amount, + receivingCurrency = receivingCurrency, + expiration = expiration, + isSubjectToTravelRule = isSubjectToTravelRule, + umaVersion = umaVersion, + commentCharsAllowed = commentCharsAllowed, + senderUma = senderUma, + invoiceLimit = invoiceLimit, + callback = callback, + kycStatus = kycStatus, + requiredPayerData = requiredPayerData, + ).apply { + val signedPayload = signPayload(toTLV(), privateSigningKey) + signature = signedPayload.toByteArray(Charsets.UTF_8) + } + } } interface UmaInvoiceCreator { @@ -893,11 +938,7 @@ interface UmaInvoiceCreator { * @return The encoded BOLT-11 invoice that should be returned to the sender for the given [PayRequest] wrapped in a * [CompletableFuture]. */ - fun createUmaInvoice( - amountMsats: Long, - metadata: String, - receiverIdentifier: String?, - ): CompletableFuture + fun createUmaInvoice(amountMsats: Long, metadata: String, receiverIdentifier: String?): CompletableFuture } interface SyncUmaInvoiceCreator { @@ -911,9 +952,5 @@ interface SyncUmaInvoiceCreator { * @param receiverIdentifier Optional identifier of the receiver. * @return The encoded BOLT-11 invoice that should be returned to the sender for the given [PayRequest]. */ - fun createUmaInvoice( - amountMsats: Long, - metadata: String, - receiverIdentifier: String?, - ): String + fun createUmaInvoice(amountMsats: Long, metadata: String, receiverIdentifier: String?): String } diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/UmaRequester.kt b/uma-sdk/src/commonMain/kotlin/me/uma/UmaRequester.kt index 1439ace..37cb5eb 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/UmaRequester.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/UmaRequester.kt @@ -15,14 +15,16 @@ interface UmaRequester { /** * A Ktor-based implementation of [UmaRequester]. */ -class KtorUmaRequester @JvmOverloads constructor(private val client: HttpClient = HttpClient()) : UmaRequester { - override suspend fun makeGetRequest(url: String): String { - val response = client.get(url) +class KtorUmaRequester + @JvmOverloads + constructor(private val client: HttpClient = HttpClient()) : UmaRequester { + override suspend fun makeGetRequest(url: String): String { + val response = client.get(url) - if (response.status.isSuccess()) { - return response.bodyAsText() - } else { - throw Exception("Error making request: ${response.status}") + if (response.status.isSuccess()) { + return response.bodyAsText() + } else { + throw Exception("Error making request: ${response.status}") + } } } -} diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/Version.kt b/uma-sdk/src/commonMain/kotlin/me/uma/Version.kt index e11f143..6e40108 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/Version.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/Version.kt @@ -1,9 +1,9 @@ package me.uma +import me.uma.utils.serialFormat import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put -import me.uma.utils.serialFormat const val MAJOR_VERSION = 1 const val MINOR_VERSION = 0 @@ -58,20 +58,22 @@ class UnsupportedVersionException( } fun isVersionSupported(versionString: String): Boolean { - val version = try { - Version.parse(versionString) - } catch (e: IllegalArgumentException) { - return false - } catch (e: NumberFormatException) { - return false - } + val version = + try { + Version.parse(versionString) + } catch (e: IllegalArgumentException) { + return false + } catch (e: NumberFormatException) { + return false + } return supportedMajorVersions().contains(version.major) } fun selectHighestSupportedVersion(otherVaspSupportedMajorVersions: List): String? { - val highestSupportedMajorVersion = otherVaspSupportedMajorVersions.filter { - supportedMajorVersions().contains(it) - }.maxOrNull() ?: return null + val highestSupportedMajorVersion = + otherVaspSupportedMajorVersions.filter { + supportedMajorVersions().contains(it) + }.maxOrNull() ?: return null return getHighestSupportedVersionForMajorVersion(highestSupportedMajorVersion) } diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/crypto/Bech32.kt b/uma-sdk/src/commonMain/kotlin/me/uma/crypto/Bech32.kt new file mode 100644 index 0000000..eb311c9 --- /dev/null +++ b/uma-sdk/src/commonMain/kotlin/me/uma/crypto/Bech32.kt @@ -0,0 +1,30 @@ +package me.uma.crypto + +/** + * wrapper class for bech32 conversion functions + */ +object Bech32 { + fun encodeBech32(hrp: String, data: ByteArray): String { + return me.uma.crypto.internal.encodeBech32( + hrp, + data.toByteList(), + ) + } + + fun decodeBech32(bech32str: String): Bech32Data { + val data = me.uma.crypto.internal.decodeBech32(bech32str) + return Bech32Data( + data.hrp, + data.data.toByteArray(), + ) + } + + private fun ByteArray.toByteList() = map { it.toUByte() } + + private fun List.toByteArray() = map { it.toByte() }.toByteArray() + + data class Bech32Data( + val hrp: String, + val data: ByteArray, + ) +} diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/crypto/Utils.kt b/uma-sdk/src/commonMain/kotlin/me/uma/crypto/Utils.kt index 9d32f9c..9b3f937 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/crypto/Utils.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/crypto/Utils.kt @@ -5,9 +5,10 @@ package me.uma.crypto internal fun String.hexToByteArray(): ByteArray { check(length % 2 == 0) { "Must have an even length" } - val byteIterator = chunkedSequence(2) - .map { it.toInt(16).toByte() } - .iterator() + val byteIterator = + chunkedSequence(2) + .map { it.toInt(16).toByte() } + .iterator() return ByteArray(length / 2) { byteIterator.next() } } diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/crypto/internal/UmaCrypto.kt b/uma-sdk/src/commonMain/kotlin/me/uma/crypto/internal/UmaCrypto.kt index 19abdc0..531468c 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/crypto/internal/UmaCrypto.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/crypto/internal/UmaCrypto.kt @@ -39,14 +39,18 @@ open class RustBuffer : Structure() { @JvmField var data: Pointer? = null - class ByValue : RustBuffer(), Structure.ByValue + class ByValue : + RustBuffer(), + Structure.ByValue - class ByReference : RustBuffer(), Structure.ByReference + class ByReference : + RustBuffer(), + Structure.ByReference companion object { internal fun alloc(size: Int = 0) = rustCall { status -> - _UniFFILib.INSTANCE.ffi_uma_crypto_b9a_rustbuffer_alloc(size, status).also { + _UniFFILib.INSTANCE.ffi_uma_crypto_338f_rustbuffer_alloc(size, status).also { if (it.data == null) { throw RuntimeException("RustBuffer.alloc() returned null data pointer (size=$size)") } @@ -55,7 +59,7 @@ open class RustBuffer : Structure() { internal fun free(buf: RustBuffer.ByValue) = rustCall { status -> - _UniFFILib.INSTANCE.ffi_uma_crypto_b9a_rustbuffer_free(buf, status) + _UniFFILib.INSTANCE.ffi_uma_crypto_338f_rustbuffer_free(buf, status) } } @@ -97,7 +101,9 @@ open class ForeignBytes : Structure() { @JvmField var data: Pointer? = null - class ByValue : ForeignBytes(), Structure.ByValue + class ByValue : + ForeignBytes(), + Structure.ByValue } // The FfiConverter interface handles converter types to and from the FFI @@ -186,20 +192,16 @@ internal open class RustCallStatus : Structure() { @JvmField var error_buf: RustBuffer.ByValue = RustBuffer.ByValue() - fun isSuccess(): Boolean { - return code == 0 - } + fun isSuccess(): Boolean = code == 0 - fun isError(): Boolean { - return code == 1 - } + fun isError(): Boolean = code == 1 - fun isPanic(): Boolean { - return code == 2 - } + fun isPanic(): Boolean = code == 2 } -class InternalException(message: String) : Exception(message) +class InternalException( + message: String, +) : Exception(message) // Each top-level error class has a companion object that can lift the error from the call status's rust buffer interface CallStatusErrorHandler { @@ -244,9 +246,7 @@ object NullCallStatusErrorHandler : CallStatusErrorHandler { } // Call a rust function that returns a plain value -private inline fun rustCall(callback: (RustCallStatus) -> U): U { - return rustCallWithError(NullCallStatusErrorHandler, callback) -} +private inline fun rustCall(callback: (RustCallStatus) -> U): U = rustCallWithError(NullCallStatusErrorHandler, callback) // Contains loading, initialization code, // and the FFI Function declarations in a com.sun.jna.Library. @@ -259,9 +259,8 @@ private fun findLibraryName(componentName: String): String { return "uniffi_uma_crypto" } -private inline fun loadIndirect(componentName: String): Lib { - return Native.load(findLibraryName(componentName), Lib::class.java) -} +private inline fun loadIndirect(componentName: String): Lib = + Native.load(findLibraryName(componentName), Lib::class.java) // A JNA Library to expose the extern-C FFI definitions. // This is an implementation detail which will be called internally by the public API. @@ -273,64 +272,75 @@ internal interface _UniFFILib : Library { } } - fun ffi_uma_crypto_b9a_KeyPair_object_free( + fun ffi_uma_crypto_338f_KeyPair_object_free( `ptr`: Pointer, _uniffi_out_err: RustCallStatus, ): Unit - fun uma_crypto_b9a_KeyPair_get_public_key( + fun uma_crypto_338f_KeyPair_get_public_key( `ptr`: Pointer, _uniffi_out_err: RustCallStatus, ): RustBuffer.ByValue - fun uma_crypto_b9a_KeyPair_get_private_key( + fun uma_crypto_338f_KeyPair_get_private_key( `ptr`: Pointer, _uniffi_out_err: RustCallStatus, ): RustBuffer.ByValue - fun uma_crypto_b9a_sign_ecdsa( + fun uma_crypto_338f_sign_ecdsa( `msg`: RustBuffer.ByValue, `privateKeyBytes`: RustBuffer.ByValue, _uniffi_out_err: RustCallStatus, ): RustBuffer.ByValue - fun uma_crypto_b9a_verify_ecdsa( + fun uma_crypto_338f_verify_ecdsa( `msg`: RustBuffer.ByValue, `signatureBytes`: RustBuffer.ByValue, `publicKeyBytes`: RustBuffer.ByValue, _uniffi_out_err: RustCallStatus, ): Byte - fun uma_crypto_b9a_encrypt_ecies( + fun uma_crypto_338f_encrypt_ecies( `msg`: RustBuffer.ByValue, `publicKeyBytes`: RustBuffer.ByValue, _uniffi_out_err: RustCallStatus, ): RustBuffer.ByValue - fun uma_crypto_b9a_decrypt_ecies( + fun uma_crypto_338f_decrypt_ecies( `cipherText`: RustBuffer.ByValue, `privateKeyBytes`: RustBuffer.ByValue, _uniffi_out_err: RustCallStatus, ): RustBuffer.ByValue - fun uma_crypto_b9a_generate_keypair(_uniffi_out_err: RustCallStatus): Pointer + fun uma_crypto_338f_generate_keypair(_uniffi_out_err: RustCallStatus): Pointer - fun ffi_uma_crypto_b9a_rustbuffer_alloc( + fun uma_crypto_338f_encode_bech32( + `hrp`: RustBuffer.ByValue, + `messageData`: RustBuffer.ByValue, + _uniffi_out_err: RustCallStatus, + ): RustBuffer.ByValue + + fun uma_crypto_338f_decode_bech32( + `bech32Str`: RustBuffer.ByValue, + _uniffi_out_err: RustCallStatus, + ): RustBuffer.ByValue + + fun ffi_uma_crypto_338f_rustbuffer_alloc( `size`: Int, _uniffi_out_err: RustCallStatus, ): RustBuffer.ByValue - fun ffi_uma_crypto_b9a_rustbuffer_from_bytes( + fun ffi_uma_crypto_338f_rustbuffer_from_bytes( `bytes`: ForeignBytes.ByValue, _uniffi_out_err: RustCallStatus, ): RustBuffer.ByValue - fun ffi_uma_crypto_b9a_rustbuffer_free( + fun ffi_uma_crypto_338f_rustbuffer_free( `buf`: RustBuffer.ByValue, _uniffi_out_err: RustCallStatus, ): Unit - fun ffi_uma_crypto_b9a_rustbuffer_reserve( + fun ffi_uma_crypto_338f_rustbuffer_reserve( `buf`: RustBuffer.ByValue, `additional`: Int, _uniffi_out_err: RustCallStatus, @@ -340,17 +350,11 @@ internal interface _UniFFILib : Library { // Public interface members begin here. public object FfiConverterUByte : FfiConverter { - override fun lift(value: Byte): UByte { - return value.toUByte() - } + override fun lift(value: Byte): UByte = value.toUByte() - override fun read(buf: ByteBuffer): UByte { - return lift(buf.get()) - } + override fun read(buf: ByteBuffer): UByte = lift(buf.get()) - override fun lower(value: UByte): Byte { - return value.toByte() - } + override fun lower(value: UByte): Byte = value.toByte() override fun allocationSize(value: UByte) = 1 @@ -363,17 +367,11 @@ public object FfiConverterUByte : FfiConverter { } public object FfiConverterBoolean : FfiConverter { - override fun lift(value: Byte): Boolean { - return value.toInt() != 0 - } + override fun lift(value: Byte): Boolean = value.toInt() != 0 - override fun read(buf: ByteBuffer): Boolean { - return lift(buf.get()) - } + override fun read(buf: ByteBuffer): Boolean = lift(buf.get()) - override fun lower(value: Boolean): Byte { - return if (value) 1.toByte() else 0.toByte() - } + override fun lower(value: Boolean): Byte = if (value) 1.toByte() else 0.toByte() override fun allocationSize(value: Boolean) = 1 @@ -447,7 +445,8 @@ interface Disposable { companion object { fun destroy(vararg args: Any?) { - args.filterIsInstance() + args + .filterIsInstance() .forEach(Disposable::destroy) } } @@ -548,7 +547,8 @@ inline fun T.use(block: (T) -> R) = // abstract class FFIObject( protected val pointer: Pointer, -) : Disposable, AutoCloseable { +) : Disposable, + AutoCloseable { private val wasDestroyed = AtomicBoolean(false) private val callCounter = AtomicLong(1) @@ -604,7 +604,8 @@ public interface KeyPairInterface { class KeyPair( pointer: Pointer, -) : FFIObject(pointer), KeyPairInterface { +) : FFIObject(pointer), + KeyPairInterface { /** * Disconnect the object from the underlying Rust object. * @@ -615,14 +616,14 @@ class KeyPair( */ protected override fun freeRustArcPtr() { rustCall { status -> - _UniFFILib.INSTANCE.ffi_uma_crypto_b9a_KeyPair_object_free(this.pointer, status) + _UniFFILib.INSTANCE.ffi_uma_crypto_338f_KeyPair_object_free(this.pointer, status) } } override fun `getPublicKey`(): List = callWithPointer { rustCall { _status -> - _UniFFILib.INSTANCE.uma_crypto_b9a_KeyPair_get_public_key(it, _status) + _UniFFILib.INSTANCE.uma_crypto_338f_KeyPair_get_public_key(it, _status) } }.let { FfiConverterSequenceUByte.lift(it) @@ -631,7 +632,7 @@ class KeyPair( override fun `getPrivateKey`(): List = callWithPointer { rustCall { _status -> - _UniFFILib.INSTANCE.uma_crypto_b9a_KeyPair_get_private_key(it, _status) + _UniFFILib.INSTANCE.uma_crypto_338f_KeyPair_get_private_key(it, _status) } }.let { FfiConverterSequenceUByte.lift(it) @@ -641,9 +642,7 @@ class KeyPair( public object FfiConverterTypeKeyPair : FfiConverter { override fun lower(value: KeyPair): Pointer = value.callWithPointer { it } - override fun lift(value: Pointer): KeyPair { - return KeyPair(value) - } + override fun lift(value: Pointer): KeyPair = KeyPair(value) override fun read(buf: ByteBuffer): KeyPair { // The Rust code always writes pointers as 8 bytes, and will @@ -663,10 +662,86 @@ public object FfiConverterTypeKeyPair : FfiConverter { } } -sealed class CryptoException(message: String) : Exception(message) { +data class Bech32Data( + var `data`: List, + var `hrp`: String, +) + +public object FfiConverterTypeBech32Data : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): Bech32Data = + Bech32Data( + FfiConverterSequenceUByte.read(buf), + FfiConverterString.read(buf), + ) + + override fun allocationSize(value: Bech32Data) = + ( + FfiConverterSequenceUByte.allocationSize(value.`data`) + + FfiConverterString.allocationSize(value.`hrp`) + ) + + override fun write( + value: Bech32Data, + buf: ByteBuffer, + ) { + FfiConverterSequenceUByte.write(value.`data`, buf) + FfiConverterString.write(value.`hrp`, buf) + } +} + +sealed class Bech32Exception( + message: String, +) : Exception(message) { // Each variant is a nested class // Flat enums carries a string error message, so no special implementation is necessary. - class Secp256k1Exception(message: String) : CryptoException(message) + class Bech32EncodeException( + message: String, + ) : Bech32Exception(message) + + class Bech32DecodeException( + message: String, + ) : Bech32Exception(message) + + companion object ErrorHandler : CallStatusErrorHandler { + override fun lift(error_buf: RustBuffer.ByValue): Bech32Exception = FfiConverterTypeBech32Error.lift(error_buf) + } +} + +public object FfiConverterTypeBech32Error : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): Bech32Exception = + when (buf.getInt()) { + 1 -> Bech32Exception.Bech32EncodeException(FfiConverterString.read(buf)) + 2 -> Bech32Exception.Bech32DecodeException(FfiConverterString.read(buf)) + else -> throw RuntimeException("invalid error enum value, something is very wrong!!") + } + + override fun allocationSize(value: Bech32Exception): Int = 4 + + override fun write( + value: Bech32Exception, + buf: ByteBuffer, + ) { + when (value) { + is Bech32Exception.Bech32EncodeException -> { + buf.putInt(1) + Unit + } + is Bech32Exception.Bech32DecodeException -> { + buf.putInt(2) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + +sealed class CryptoException( + message: String, +) : Exception(message) { + // Each variant is a nested class + // Flat enums carries a string error message, so no special implementation is necessary. + class Secp256k1Exception( + message: String, + ) : CryptoException(message) companion object ErrorHandler : CallStatusErrorHandler { override fun lift(error_buf: RustBuffer.ByValue): CryptoException = FfiConverterTypeCryptoError.lift(error_buf) @@ -674,16 +749,13 @@ sealed class CryptoException(message: String) : Exception(message) { } public object FfiConverterTypeCryptoError : FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): CryptoException { - return when (buf.getInt()) { + override fun read(buf: ByteBuffer): CryptoException = + when (buf.getInt()) { 1 -> CryptoException.Secp256k1Exception(FfiConverterString.read(buf)) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } - } - override fun allocationSize(value: CryptoException): Int { - return 4 - } + override fun allocationSize(value: CryptoException): Int = 4 override fun write( value: CryptoException, @@ -727,27 +799,26 @@ public object FfiConverterSequenceUByte : FfiConverterRustBuffer> { fun `signEcdsa`( `msg`: List, `privateKeyBytes`: List, -): List { - return FfiConverterSequenceUByte.lift( +): List = + FfiConverterSequenceUByte.lift( rustCallWithError(CryptoException) { _status -> - _UniFFILib.INSTANCE.uma_crypto_b9a_sign_ecdsa( + _UniFFILib.INSTANCE.uma_crypto_338f_sign_ecdsa( FfiConverterSequenceUByte.lower(`msg`), FfiConverterSequenceUByte.lower(`privateKeyBytes`), _status, ) }, ) -} @Throws(CryptoException::class) fun `verifyEcdsa`( `msg`: List, `signatureBytes`: List, `publicKeyBytes`: List, -): Boolean { - return FfiConverterBoolean.lift( +): Boolean = + FfiConverterBoolean.lift( rustCallWithError(CryptoException) { _status -> - _UniFFILib.INSTANCE.uma_crypto_b9a_verify_ecdsa( + _UniFFILib.INSTANCE.uma_crypto_338f_verify_ecdsa( FfiConverterSequenceUByte.lower(`msg`), FfiConverterSequenceUByte.lower(`signatureBytes`), FfiConverterSequenceUByte.lower(`publicKeyBytes`), @@ -755,45 +826,64 @@ fun `verifyEcdsa`( ) }, ) -} @Throws(CryptoException::class) fun `encryptEcies`( `msg`: List, `publicKeyBytes`: List, -): List { - return FfiConverterSequenceUByte.lift( +): List = + FfiConverterSequenceUByte.lift( rustCallWithError(CryptoException) { _status -> - _UniFFILib.INSTANCE.uma_crypto_b9a_encrypt_ecies( + _UniFFILib.INSTANCE.uma_crypto_338f_encrypt_ecies( FfiConverterSequenceUByte.lower(`msg`), FfiConverterSequenceUByte.lower(`publicKeyBytes`), _status, ) }, ) -} @Throws(CryptoException::class) fun `decryptEcies`( `cipherText`: List, `privateKeyBytes`: List, -): List { - return FfiConverterSequenceUByte.lift( +): List = + FfiConverterSequenceUByte.lift( rustCallWithError(CryptoException) { _status -> - _UniFFILib.INSTANCE.uma_crypto_b9a_decrypt_ecies( + _UniFFILib.INSTANCE.uma_crypto_338f_decrypt_ecies( FfiConverterSequenceUByte.lower(`cipherText`), FfiConverterSequenceUByte.lower(`privateKeyBytes`), _status, ) }, ) -} @Throws(CryptoException::class) -fun `generateKeypair`(): KeyPair { - return FfiConverterTypeKeyPair.lift( +fun `generateKeypair`(): KeyPair = + FfiConverterTypeKeyPair.lift( rustCallWithError(CryptoException) { _status -> - _UniFFILib.INSTANCE.uma_crypto_b9a_generate_keypair(_status) + _UniFFILib.INSTANCE.uma_crypto_338f_generate_keypair(_status) + }, + ) + +@Throws(Bech32Exception::class) +fun `encodeBech32`( + `hrp`: String, + `messageData`: List, +): String = + FfiConverterString.lift( + rustCallWithError(Bech32Exception) { _status -> + _UniFFILib.INSTANCE.uma_crypto_338f_encode_bech32( + FfiConverterString.lower(`hrp`), + FfiConverterSequenceUByte.lower(`messageData`), + _status, + ) + }, + ) + +@Throws(Bech32Exception::class) +fun `decodeBech32`(`bech32Str`: String): Bech32Data = + FfiConverterTypeBech32Data.lift( + rustCallWithError(Bech32Exception) { _status -> + _UniFFILib.INSTANCE.uma_crypto_338f_decode_bech32(FfiConverterString.lower(`bech32Str`), _status) }, ) -} 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 f1395dd..2a445d5 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Currency.kt @@ -2,13 +2,13 @@ package me.uma.protocol +import me.uma.UMA_VERSION_STRING +import me.uma.Version import kotlinx.serialization.SerialName 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 { /** @@ -91,10 +91,11 @@ fun createCurrency( name = name, symbol = symbol, millisatoshiPerUnit = millisatoshiPerUnit, - convertible = CurrencyConvertible( - min = minSendable, - max = maxSendable, - ), + convertible = + CurrencyConvertible( + min = minSendable, + max = maxSendable, + ), decimals = decimals, ) } @@ -107,16 +108,13 @@ internal 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. */ val convertible: CurrencyConvertible, - override val decimals: Int, ) : Currency { - override fun minSendable() = convertible.min override fun maxSendable() = convertible.max @@ -129,22 +127,18 @@ internal 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). */ val minSendable: Long, - /** * Maximum amount that can be sent in this currency. This is in the smallest unit of the * currency (eg. cents for USD). */ val maxSendable: Long, - override val decimals: Int, ) : Currency { - override fun minSendable() = minSendable override fun maxSendable() = maxSendable diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Invoice.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Invoice.kt new file mode 100644 index 0000000..0cdda2f --- /dev/null +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Invoice.kt @@ -0,0 +1,309 @@ +package me.uma.protocol + +import io.ktor.utils.io.core.toByteArray +import me.uma.crypto.Bech32 +import me.uma.utils.* +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +private const val UMA_BECH32_PREFIX = "uma" +typealias MalformedUmaInvoiceException = IllegalArgumentException + +@Serializable(with = InvoiceCurrencyTLVSerializer::class) +data class InvoiceCurrency( + val code: String, + val name: String, + val symbol: String, + val decimals: Int, +) : TLVCodeable { + companion object { + val EMPTY = InvoiceCurrency("", "", "", 0) + + fun fromTLV(bytes: ByteArray): InvoiceCurrency { + var code = "" + var name = "" + var symbol = "" + var decimals = -1 + var offset = 0 + while (offset < bytes.size) { + val length = bytes[offset.lengthOffset()].toInt() + when (bytes[offset].toInt()) { + 0 -> code = bytes.getString(offset.valueOffset(), length) + 1 -> name = bytes.getString(offset.valueOffset(), length) + 2 -> symbol = bytes.getString(offset.valueOffset(), length) + 3 -> decimals = bytes.getNumber(offset.valueOffset(), length) + } + offset = offset.valueOffset() + length + } + return InvoiceCurrency(code = code, name = name, symbol = symbol, decimals = decimals) + } + } + + override fun toTLV() = mutableListOf() + .putString(0, code) + .putString(1, name) + .putString(2, symbol) + .putNumber(3, decimals) + .array() +} + +@OptIn(ExperimentalSerializationApi::class) +class InvoiceCurrencyTLVSerializer : KSerializer { + private val delegateSerializer = ByteArraySerializer() + override val descriptor = SerialDescriptor("InvoiceCurrency", delegateSerializer.descriptor) + + override fun serialize(encoder: Encoder, value: InvoiceCurrency) { + encoder.encodeSerializableValue( + delegateSerializer, + value.toTLV(), + ) + } + + override fun deserialize(decoder: Decoder) = InvoiceCurrency.fromTLV( + decoder.decodeSerializableValue(delegateSerializer), + ) +} + +@Serializable(with = InvoiceTLVSerializer::class) +class Invoice( + val receiverUma: String, + /** Invoice UUID Served as both the identifier of the UMA invoice, and the validation of proof of payment.*/ + val invoiceUUID: String, + /** The amount of invoice to be paid in the smallest unit of the ReceivingCurrency. */ + val amount: Int, + /** The currency of the invoice */ + val receivingCurrency: InvoiceCurrency, + /** The unix timestamp the UMA invoice expires */ + val expiration: Int, + /** Indicates whether the VASP is a financial institution that requires travel rule information. */ + val isSubjectToTravelRule: Boolean, + /** RequiredPayerData the data about the payer that the sending VASP must provide in order to send a payment. */ + val requiredPayerData: CounterPartyDataOptions? = null, + /** UmaVersion is a list of UMA versions that the VASP supports for this transaction. It should be + * containing the lowest minor version of each major version it supported, separated by commas. + */ + val umaVersion: String, + /** CommentCharsAllowed is the number of characters that the sender can include in the comment field of the pay request. */ + val commentCharsAllowed: Int? = null, + /** The sender's UMA address. If this field presents, the UMA invoice should directly go to the sending VASP instead of showing in other formats. */ + val senderUma: String? = null, + /** The maximum number of the invoice can be paid */ + val invoiceLimit: Int? = null, + /** YC status of the receiver, default is verified. */ + val kycStatus: KycStatus? = null, + /** The callback url that the sender should send the PayRequest to. */ + val callback: String, + /** The signature of the UMA invoice */ + var signature: ByteArray? = null, +) : TLVCodeable { + override fun toTLV() = mutableListOf() + .putString(0, receiverUma) + .putString(1, invoiceUUID) + .putNumber(2, amount) + .putTLVCodeable(3, receivingCurrency) + .putNumber(4, expiration) + .putBoolean(5, isSubjectToTravelRule) + .putByteCodeable(6, requiredPayerData?.let(::InvoiceCounterPartyDataOptions)) + .putString(7, umaVersion) + .putNumber(8, commentCharsAllowed) + .putString(9, senderUma) + .putNumber(10, invoiceLimit) + .putByteCodeable(11, kycStatus?.let(::InvoiceKycStatus)) + .putString(12, callback) + .putByteArray(100, signature) + .array() + + fun toBech32(): String { + return Bech32.encodeBech32( + UMA_BECH32_PREFIX, + this.toTLV(), + ) + } + + companion object { + fun fromTLV(bytes: ByteArray): Invoice { + val ib = InvoiceBuilder() + var offset = 0 + while (offset < bytes.size) { + val length = bytes[offset.lengthOffset()].toInt() + when (bytes[offset].toInt()) { + 0 -> ib.receiverUma = bytes.getString(offset.valueOffset(), length) + 1 -> ib.invoiceUUID = bytes.getString(offset.valueOffset(), length) + 2 -> ib.amount = bytes.getNumber(offset.valueOffset(), length) + 3 -> + ib.receivingCurrency = + bytes.getTLV(offset.valueOffset(), length, InvoiceCurrency::fromTLV) as InvoiceCurrency + + 4 -> ib.expiration = bytes.getNumber(offset.valueOffset(), length) + 5 -> ib.isSubjectToTravelRule = bytes.getBoolean(offset.valueOffset()) + 6 -> + ib.requiredPayerData = + ( + bytes.getByteCodeable( + offset.valueOffset(), + length, + InvoiceCounterPartyDataOptions::fromBytes, + ) as InvoiceCounterPartyDataOptions + ).options + + 7 -> ib.umaVersion = bytes.getString(offset.valueOffset(), length) + 8 -> ib.commentCharsAllowed = bytes.getNumber(offset.valueOffset(), length) + 9 -> ib.senderUma = bytes.getString(offset.valueOffset(), length) + 10 -> ib.invoiceLimit = bytes.getNumber(offset.valueOffset(), length) + 11 -> + ib.kycStatus = ( + bytes.getByteCodeable( + offset.valueOffset(), + length, + InvoiceKycStatus::fromBytes, + ) as InvoiceKycStatus + ).status + + 12 -> ib.callback = bytes.getString(offset.valueOffset(), length) + 100 -> + ib.signature = + bytes.sliceArray( + offset.valueOffset().. { + private val delegateSerializer = ByteArraySerializer() + override val descriptor = SerialDescriptor("Invoice", delegateSerializer.descriptor) + + override fun serialize(encoder: Encoder, value: Invoice) { + encoder.encodeSerializableValue( + delegateSerializer, + value.toTLV(), + ) + } + + override fun deserialize(decoder: Decoder) = Invoice.fromTLV( + decoder.decodeSerializableValue(delegateSerializer), + ) +} + +data class InvoiceCounterPartyDataOptions( + val options: CounterPartyDataOptions, +) : ByteCodeable { + override fun toBytes(): ByteArray { + return options.entries + .sortedBy { it.key } + .joinToString(",") { (key, option) -> + "$key:${if (option.mandatory) 1 else 0}" + } + .toByteArray(Charsets.UTF_8) + } + + companion object { + fun fromBytes(bytes: ByteArray): InvoiceCounterPartyDataOptions { + val optionsString = String(bytes) + return InvoiceCounterPartyDataOptions( + optionsString.split(",").mapNotNull { + val options = it.split(':') + if (options.size == 2) { + options[0] to CounterPartyDataOption(options[1] == "1") + } else { + null + } + }.toMap(), + ) + } + } +} + +data class InvoiceKycStatus(val status: KycStatus) : ByteCodeable { + override fun toBytes(): ByteArray { + return status.rawValue.toByteArray() + } + + companion object { + fun fromBytes(bytes: ByteArray): InvoiceKycStatus { + return InvoiceKycStatus( + KycStatus.fromRawValue(bytes.toString(Charsets.UTF_8)), + ) + } + } +} diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/KycStatus.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/KycStatus.kt index 71ee629..050f214 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/KycStatus.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/KycStatus.kt @@ -1,13 +1,12 @@ package me.uma.protocol -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import me.uma.utils.EnumSerializer import me.uma.utils.serialFormat +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString @Serializable(with = KycStatusSerializer::class) enum class KycStatus(val rawValue: String) { - UNKNOWN("UNKNOWN"), NOT_VERIFIED("NOT_VERIFIED"), @@ -16,7 +15,18 @@ enum class KycStatus(val rawValue: String) { VERIFIED("VERIFIED"), ; + fun toJson() = serialFormat.encodeToString(this) + + companion object { + fun fromRawValue(rawValue: String) = when (rawValue) { + "UNKNOWN" -> UNKNOWN + "NOT_VERIFIED" -> NOT_VERIFIED + "PENDING" -> PENDING + "VERIFIED" -> VERIFIED + else -> UNKNOWN + } + } } object KycStatusSerializer : diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpRequest.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpRequest.kt index e9129ae..b7cd2e9 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpRequest.kt @@ -5,10 +5,10 @@ package me.uma.protocol import io.ktor.http.Parameters import io.ktor.http.URLBuilder import io.ktor.http.URLProtocol -import kotlin.contracts.ExperimentalContracts import me.uma.UnsupportedVersionException import me.uma.isVersionSupported import me.uma.utils.isDomainLocalhost +import kotlin.contracts.ExperimentalContracts /** * The first request in the UMA/LNURL protocol. @@ -41,19 +41,21 @@ data class LnurlpRequest( throw IllegalArgumentException("Invalid receiverAddress: $receiverAddress") } val scheme = if (isDomainLocalhost(receiverAddressParts[1])) URLProtocol.HTTP else URLProtocol.HTTPS - val url = URLBuilder( - protocol = scheme, - host = receiverAddressParts[1], - pathSegments = "/.well-known/lnurlp/${receiverAddressParts[0]}".split("/"), - parameters = Parameters.build { - vaspDomain?.let { append("vaspDomain", it) } - nonce?.let { append("nonce", it) } - signature?.let { append("signature", it) } - umaVersion?.let { append("umaVersion", it) } - timestamp?.let { append("timestamp", it.toString()) } - isSubjectToTravelRule?.let { append("isSubjectToTravelRule", it.toString()) } - }, - ).build() + val url = + URLBuilder( + protocol = scheme, + host = receiverAddressParts[1], + pathSegments = "/.well-known/lnurlp/${receiverAddressParts[0]}".split("/"), + parameters = + Parameters.build { + vaspDomain?.let { append("vaspDomain", it) } + nonce?.let { append("nonce", it) } + signature?.let { append("signature", it) } + umaVersion?.let { append("umaVersion", it) } + timestamp?.let { append("timestamp", it.toString()) } + isSubjectToTravelRule?.let { append("isSubjectToTravelRule", it.toString()) } + }, + ).build() return url.toString() } @@ -94,11 +96,12 @@ data class LnurlpRequest( ) { throw IllegalArgumentException("Invalid uma request path: $url") } - val port = if (urlBuilder.port != 443 && urlBuilder.port != 80 && urlBuilder.port != 0) { - ":${urlBuilder.port}" - } else { - "" - } + val port = + if (urlBuilder.port != 443 && urlBuilder.port != 80 && urlBuilder.port != 0) { + ":${urlBuilder.port}" + } else { + "" + } val receiverAddress = "${urlBuilder.pathSegments[3]}@${urlBuilder.host}$port" val vaspDomain = urlBuilder.parameters["vaspDomain"] val nonce = urlBuilder.parameters["nonce"] 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 4ab085b..e97d766 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpResponse.kt @@ -1,7 +1,7 @@ package me.uma.protocol -import kotlinx.serialization.* import me.uma.utils.serialFormat +import kotlinx.serialization.* /** * Response from VASP2 to the [LnurlpRequest]. @@ -30,7 +30,10 @@ data class LnurlpResponse( val minSendable: Long, val maxSendable: Long, val metadata: String, - val currencies: List<@Serializable(with = CurrencySerializer::class) Currency>?, + val currencies: List< + @Serializable(with = CurrencySerializer::class) + Currency + >?, @SerialName("payerData") val requiredPayerData: CounterPartyDataOptions?, val compliance: LnurlComplianceResponse?, @@ -42,30 +45,29 @@ data class LnurlpResponse( @EncodeDefault val tag: String = "payRequest", ) { - fun asUmaResponse(): UmaLnurlpResponse? = - if ( - currencies != null && - requiredPayerData != null && - compliance != null && - umaVersion != null - ) { - UmaLnurlpResponse( - callback, - minSendable, - maxSendable, - metadata, - currencies, - requiredPayerData, - compliance, - umaVersion, - commentCharsAllowed, - nostrPubkey, - allowsNostr, - tag, - ) - } else { - null - } + fun asUmaResponse(): UmaLnurlpResponse? = if ( + currencies != null && + requiredPayerData != null && + compliance != null && + umaVersion != null + ) { + UmaLnurlpResponse( + callback, + minSendable, + maxSendable, + metadata, + currencies, + requiredPayerData, + compliance, + umaVersion, + commentCharsAllowed, + nostrPubkey, + allowsNostr, + tag, + ) + } else { + null + } fun toJson() = serialFormat.encodeToString(this) } @@ -77,7 +79,10 @@ data class UmaLnurlpResponse( val minSendable: Long, val maxSendable: Long, val metadata: String, - val currencies: List<@Serializable(with = CurrencySerializer::class) Currency>, + val currencies: List< + @Serializable(with = CurrencySerializer::class) + Currency + >, @SerialName("payerData") val requiredPayerData: CounterPartyDataOptions, val compliance: LnurlComplianceResponse, diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt index 552027c..7b02b3d 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt @@ -1,10 +1,10 @@ package me.uma.protocol +import me.uma.utils.serialFormat import kotlinx.serialization.* import kotlinx.serialization.json.JsonContentPolymorphicSerializer import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonObject -import me.uma.utils.serialFormat /** * The response sent by the receiver to the sender to provide an invoice. @@ -45,16 +45,13 @@ internal data class PayReqResponseV1( override val encodedInvoice: String, @SerialName("converted") override val paymentInfo: V1PayReqResponsePaymentInfo?, - /** * 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 @@ -62,7 +59,6 @@ internal data class PayReqResponseV1( * 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. */ @@ -84,8 +80,9 @@ internal data class PayReqResponseV1( 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") - val complianceData = payeeData.payeeCompliance() - ?: throw IllegalArgumentException("Compliance data is required") + val complianceData = + payeeData.payeeCompliance() + ?: throw IllegalArgumentException("Compliance data is required") return complianceData.let { "$payerIdentifier|${payeeData.identifier()}|${it.signatureNonce}|${it.signatureTimestamp}" .encodeToByteArray() @@ -98,12 +95,10 @@ internal data class PayReqResponseV1( internal data class PayReqResponseV0 constructor( @SerialName("pr") override val encodedInvoice: String, - /** * The compliance data from the receiver, including utxo info. */ val compliance: PayReqResponseCompliance, - override val paymentInfo: V0PayReqResponsePaymentInfo, @EncodeDefault override val routes: List = emptyList(), 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 7606093..d1e5c1f 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt @@ -1,5 +1,6 @@ package me.uma.protocol +import me.uma.utils.serialFormat import kotlinx.serialization.* import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.nullable @@ -11,7 +12,6 @@ import kotlinx.serialization.encoding.* import kotlinx.serialization.json.JsonContentPolymorphicSerializer import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonObject -import me.uma.utils.serialFormat /** * The request sent by the sender to the receiver to retrieve an invoice. @@ -55,20 +55,22 @@ sealed interface PayRequest { fun fromQueryParamMap(queryMap: Map>): PayRequest { val receivingCurrencyCode = queryMap["convert"]?.firstOrNull() - val amountStr = queryMap["amount"]?.firstOrNull() - ?: throw IllegalArgumentException("Amount is required") + 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 requestedPayeeData = + queryMap["payeeData"]?.firstOrNull()?.let { + serialFormat.decodeFromString( + MapSerializer(String.serializer(), CounterPartyDataOption.serializer()), + it, + ) + } val comment = queryMap["comment"]?.firstOrNull() return PayRequestV1( sendingCurrencyCode, @@ -88,12 +90,10 @@ internal data class PayRequestV1( 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 @@ -101,7 +101,6 @@ internal data class PayRequestV1( */ val comment: String? = null, ) : PayRequest { - override fun receivingCurrencyCode() = receivingCurrencyCode override fun sendingCurrencyCode() = sendingCurrencyCode @@ -124,14 +123,16 @@ internal data class PayRequestV1( override fun comment(): String? = comment override fun toQueryParamMap(): Map { - val amountStr = if (sendingCurrencyCode != null) { - "$amount.$sendingCurrencyCode" - } else { - amount.toString() - } - val map = mutableMapOf( - "amount" to amountStr, - ) + val amountStr = + if (sendingCurrencyCode != null) { + "$amount.$sendingCurrencyCode" + } else { + amount.toString() + } + val map = + mutableMapOf( + "amount" to amountStr, + ) receivingCurrencyCode?.let { map["convert"] = it } payerData?.let { map["payerData"] = serialFormat.encodeToString(it) } requestedPayeeData?.let { @@ -149,7 +150,6 @@ internal data class PayRequestV0( */ @SerialName("currency") val currencyCode: String, - override val amount: Long, override val payerData: PayerData, ) : PayRequest { @@ -163,11 +163,10 @@ internal data class PayRequestV0( override fun comment(): String? = null - override fun signablePayload() = - payerData.compliance()?.let { - "${payerData.identifier()}|${it.signatureNonce}|${it.signatureTimestamp}".encodeToByteArray() - } ?: payerData.identifier()?.encodeToByteArray() - ?: throw IllegalArgumentException("Payer identifier is required for UMA") + override fun signablePayload() = payerData.compliance()?.let { + "${payerData.identifier()}|${it.signatureNonce}|${it.signatureTimestamp}".encodeToByteArray() + } ?: payerData.identifier()?.encodeToByteArray() + ?: throw IllegalArgumentException("Payer identifier is required for UMA") override fun toJson() = serialFormat.encodeToString(this) @@ -180,13 +179,14 @@ internal data class PayRequestV0( @OptIn(ExperimentalSerializationApi::class) internal object PayRequestV1Serializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PayRequestV1") { - element("convert", isOptional = true) - element("amount") // Serialize and deserialize amount as a string - element("payerData") - element("payeeData", isOptional = true) - element("comment", isOptional = true) - } + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("PayRequestV1") { + element("convert", isOptional = true) + element("amount") // Serialize and deserialize amount as a string + element("payerData") + element("payeeData", isOptional = true) + element("comment", isOptional = true) + } override fun serialize(encoder: Encoder, value: PayRequestV1) { encoder.encodeStructure(descriptor) { @@ -224,36 +224,41 @@ internal object PayRequestV1Serializer : KSerializer { val index = decodeElementIndex(descriptor) if (index == CompositeDecoder.DECODE_DONE) break when (index) { - 0 -> receivingCurrencyCode = decodeNullableSerializableElement( - descriptor, - index, - String.serializer().nullable, - ) + 0 -> + receivingCurrencyCode = + decodeNullableSerializableElement( + descriptor, + index, + String.serializer().nullable, + ) 1 -> amount = decodeStringElement(descriptor, index) 2 -> payerData = decodeSerializableElement(descriptor, index, PayerData.serializer()) - 3 -> requestedPayeeData = decodeNullableSerializableElement( - descriptor, - index, - MapSerializer( - String.serializer(), - CounterPartyDataOption.serializer(), - ).nullable, - ) + 3 -> + requestedPayeeData = + decodeNullableSerializableElement( + descriptor, + index, + MapSerializer( + String.serializer(), + CounterPartyDataOption.serializer(), + ).nullable, + ) 4 -> comment = decodeNullableSerializableElement(descriptor, index, String.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") + 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") PayRequestV1( sendingCurrencyCode, diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayeeData.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayeeData.kt index 5a2af8b..857f2d5 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayeeData.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayeeData.kt @@ -2,13 +2,13 @@ package me.uma.protocol +import me.uma.utils.serialFormat import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement -import me.uma.utils.serialFormat typealias PayeeData = JsonObject diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayerData.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayerData.kt index ce965f5..bb0ed92 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayerData.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayerData.kt @@ -2,6 +2,7 @@ package me.uma.protocol +import me.uma.utils.serialFormat import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind @@ -14,7 +15,6 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonPrimitive -import me.uma.utils.serialFormat typealias PayerData = JsonObject @@ -25,9 +25,10 @@ fun createPayerData( name: String? = null, email: String? = null, ): PayerData { - val payerDataMap = mutableMapOf( - "identifier" to JsonPrimitive(identifier), - ) + val payerDataMap = + mutableMapOf( + "identifier" to JsonPrimitive(identifier), + ) if (compliance != null) { payerDataMap["compliance"] = serialFormat.encodeToJsonElement(compliance) } @@ -65,19 +66,21 @@ fun PayerData.identifier(): String? = get("identifier")?.jsonPrimitive?.content * indicates raw json or a custom format. */ @Serializable -data class CompliancePayerData @JvmOverloads constructor( - val utxos: List, - val nodePubKey: String?, - val kycStatus: KycStatus, - val encryptedTravelRuleInfo: String?, - val utxoCallback: String, - val signature: String, - val signatureNonce: String, - val signatureTimestamp: Long, - val travelRuleFormat: TravelRuleFormat? = null, -) { - fun signedWith(signature: String) = copy(signature = signature) -} +data class CompliancePayerData + @JvmOverloads + constructor( + val utxos: List, + val nodePubKey: String?, + val kycStatus: KycStatus, + val encryptedTravelRuleInfo: String?, + val utxoCallback: String, + val signature: String, + val signatureNonce: String, + val signatureTimestamp: Long, + val travelRuleFormat: TravelRuleFormat? = null, + ) { + fun signedWith(signature: String) = copy(signature = signature) + } /** * A standardized format of the travel rule information. diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PostTransactionCallback.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PostTransactionCallback.kt index 7f488c8..80ce5c9 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PostTransactionCallback.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PostTransactionCallback.kt @@ -1,8 +1,8 @@ package me.uma.protocol +import me.uma.utils.serialFormat import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString -import me.uma.utils.serialFormat /** * Post-payment callbacks exchanged between VASPs. 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 dd39958..fdcf973 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt @@ -1,13 +1,13 @@ package me.uma.protocol +import me.uma.utils.ByteArrayAsHexSerializer +import me.uma.utils.X509CertificateSerializer +import me.uma.utils.serialFormat import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.security.interfaces.ECPublicKey import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString -import me.uma.utils.ByteArrayAsHexSerializer -import me.uma.utils.X509CertificateSerializer -import me.uma.utils.serialFormat /** * Response from another VASP when requesting public keys. @@ -23,8 +23,14 @@ import me.uma.utils.serialFormat */ @Serializable data class PubKeyResponse internal constructor( - val signingCertChain: List<@Serializable(with = X509CertificateSerializer::class) X509Certificate>? = null, - val encryptionCertChain: List<@Serializable(with = X509CertificateSerializer::class) X509Certificate>? = null, + 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? = null, @Serializable(with = ByteArrayAsHexSerializer::class) @@ -102,8 +108,9 @@ private fun String.toX509CertChain(): List { } private fun List.getPubKeyBytes(): ByteArray { - val publicKey = firstOrNull()?.publicKey - ?: throw IllegalStateException("Certificate chain is empty") + val publicKey = + firstOrNull()?.publicKey + ?: throw IllegalStateException("Certificate chain is empty") if (publicKey !is ECPublicKey || !publicKey.params.toString().contains("secp256k1")) { throw IllegalStateException("Public key extracted from certificate is not EC/secp256k1") } 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 695b22f..7389938 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/utils/Serialization.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/utils/Serialization.kt @@ -1,23 +1,25 @@ package me.uma.utils +import me.uma.protocol.* import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule -import me.uma.protocol.* -val module = SerializersModule { - polymorphic(Currency::class, CurrencyV1::class, CurrencyV1.serializer()) - polymorphic(Currency::class, CurrencyV0::class, CurrencyV0.serializer()) - polymorphic(PayRequest::class, PayRequestV1::class, PayRequestV1Serializer) - polymorphic(PayRequest::class, PayRequestV0::class, PayRequestV0.serializer()) - polymorphic(PayReqResponse::class, PayReqResponseV1::class, PayReqResponseV1.serializer()) - polymorphic(PayReqResponse::class, PayReqResponseV0::class, PayReqResponseV0.serializer()) -} +val module = + SerializersModule { + polymorphic(Currency::class, CurrencyV1::class, CurrencyV1.serializer()) + polymorphic(Currency::class, CurrencyV0::class, CurrencyV0.serializer()) + polymorphic(PayRequest::class, PayRequestV1::class, PayRequestV1Serializer) + polymorphic(PayRequest::class, PayRequestV0::class, PayRequestV0.serializer()) + polymorphic(PayReqResponse::class, PayReqResponseV1::class, PayReqResponseV1.serializer()) + polymorphic(PayReqResponse::class, PayReqResponseV0::class, PayReqResponseV0.serializer()) + } @OptIn(ExperimentalSerializationApi::class) -val serialFormat = Json { - ignoreUnknownKeys = true - isLenient = true - explicitNulls = false - serializersModule = module -} +val serialFormat = + Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + serializersModule = module + } diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/utils/TLVUtils.kt b/uma-sdk/src/commonMain/kotlin/me/uma/utils/TLVUtils.kt new file mode 100644 index 0000000..4de5f1d --- /dev/null +++ b/uma-sdk/src/commonMain/kotlin/me/uma/utils/TLVUtils.kt @@ -0,0 +1,163 @@ +package me.uma.utils + +import java.nio.ByteBuffer + +interface TLVCodeable { + fun toTLV(): ByteArray +} + +interface ByteCodeable { + fun toBytes(): ByteArray +} + +/** + * utilities for accessing offsets of 'value' and 'len' (length) when encoding and decoding + */ +fun Int.lengthOffset() = this + 1 + +fun Int.valueOffset() = this + 2 + +fun MutableList.putString(tag: Int, value: String?): MutableList { + value?.let { + val byteStr = value.toByteArray(Charsets.UTF_8) + add( + ByteBuffer.allocate(2 + byteStr.size) + .put(tag.toByte()) + .put(byteStr.size.toByte()) + .put(byteStr) + .array() + ) + } + return this +} + +fun MutableList.putNumber(tag: Int, value: Number?): MutableList { + if (value == null) return this + val tlvBuffer = { numberSize: Int -> + ByteBuffer + .allocate(2 + numberSize) + .put(tag.toByte()) + .put(numberSize.toByte()) + } + add( + when (value) { + is Int -> { + when (value) { + in Byte.MIN_VALUE.toInt()..Byte.MAX_VALUE.toInt() -> { + tlvBuffer(Byte.SIZE_BYTES).put(value.toByte()) + } + in Short.MIN_VALUE.toInt()..Short.MAX_VALUE.toInt() -> { + tlvBuffer(Short.SIZE_BYTES).putShort(value.toShort()) + } + else -> { + tlvBuffer(Int.SIZE_BYTES).putInt(value) + } + } + } + is Short -> { + when (value) { + in Byte.MIN_VALUE..Byte.MAX_VALUE -> { + tlvBuffer(Byte.SIZE_BYTES).put(value.toByte()) + } + else -> tlvBuffer(Short.SIZE_BYTES).putShort(value.toShort()) + } + } + is Byte -> tlvBuffer(Byte.SIZE_BYTES).put(value.toByte()) + is Float -> tlvBuffer(Float.SIZE_BYTES).putFloat(value) + is Double -> tlvBuffer(Double.SIZE_BYTES).putDouble(value) + is Long -> tlvBuffer(Long.SIZE_BYTES).putLong(value) + else -> throw IllegalArgumentException("Unsupported type: ${value::class.simpleName}") + }.array() + ) + return this +} + +fun MutableList.putBoolean(tag: Int, value: Boolean): MutableList { + add( + ByteBuffer.allocate(2 + 1) + .put(tag.toByte()) + .put(1) + .put(if (value) 1 else 0) + .array() + ) + return this +} + +fun MutableList.putByteArray(tag: Int, value: ByteArray?): MutableList { + value?.let { + add( + ByteBuffer.allocate(2 + value.size) + .put(tag.toByte()) + .put(value.size.toByte()) + .put(value) + .array() + ) + } + return this +} + +fun MutableList.putByteCodeable(tag: Int, value: ByteCodeable?): MutableList { + value?.let { + val encodedBytes = it.toBytes() + add( + ByteBuffer.allocate(2 + encodedBytes.size) + .put(tag.toByte()) + .put(encodedBytes.size.toByte()) + .put(encodedBytes) + .array() + ) + } + return this +} + +fun MutableList.putTLVCodeable(tag: Int, value: TLVCodeable): MutableList { + val encodedBytes = value.toTLV() + add( + ByteBuffer.allocate(2 + encodedBytes.size) + .put(tag.toByte()) + .put(encodedBytes.size.toByte()) + .put(encodedBytes) + .array() + ) + return this +} + +fun MutableList.array(): ByteArray { + val buffer = ByteBuffer.allocate(sumOf { it.size }) + forEach(buffer::put) + return buffer.array() +} + +fun ByteArray.getNumber(offset: Int, length: Int): Int { + val buffer = ByteBuffer.wrap(slice(offset.. this[offset].toInt() + 2 -> buffer.getShort().toInt() + 4 -> buffer.getInt() + else -> this[offset].toInt() + } +} + +fun ByteArray.getFLoat(offset: Int, length: Int): Float { + // TODO throw error for wrong sized item + val buffer = ByteBuffer.wrap(slice(offset.. TLVCodeable): TLVCodeable { + return tlvDecode(slice(offset.. ByteCodeable): ByteCodeable { + return byteDecode(slice(offset..(encoded) + assertEquals("usd", result.code) + assertEquals("us dollars", result.name) + assertEquals("$", result.symbol) + assertEquals(10, result.decimals) + } + + @Test + fun `test create invoice`() = runTest { + val invoice = createInvoice() + val serializedInvoice = serialFormat.encodeToString(invoice) + val result = serialFormat.decodeFromString(serializedInvoice) + validateInvoice(invoice, result) + } + + @Test + fun `deserializing an Invoice with missing required fields triggers error`() = runTest { + val exception = + assertThrows { + // missing receiverUma, invoiceUUID, and Amount + val malformedBech32str = + "uma1qvtqqq642dzqzz242vsygmmvd3shyqspyspszqsyqsqq7sjqq5qszpsmvdhk6urvd9skucm98gcjcetdv95kcw3s93hx" + + "zmt98gcqwqes9ceskzzkg4fyj3jfg4zqc8rgw368que69uhk27rpd4cxcefwvdhk6tmrv9kxccnpvd4kgztnd9nkuct5" + + "w4ex2mxcdff" + Invoice.fromBech32(malformedBech32str) + } + assertEquals( + "missing required fields: [amount, invoiceUUID, receiverUma]", + exception.message, + ) + } + + @Test + fun `test encode invoice as bech32`() = runTest { + val invoice = createInvoice() + val bech32str = + try { + invoice.toBech32() + } catch (e: IndexOutOfBoundsException) { + "" + } + assertEquals("uma", bech32str.slice(0..2)) + assertEquals(BECH32_REFERENCE_STR, bech32str) + + val decodedInvoice = Invoice.fromBech32(bech32str) + validateInvoice(invoice, decodedInvoice) + } + + @Test + fun `test decode bech32 invoice from incoming string`() { + // sourced from python + val decodedInvoice = Invoice.fromBech32(BECH32_REFERENCE_STR) + assertEquals("\$foo@bar.com", decodedInvoice.receiverUma) + assertEquals("c7c07fec-cf00-431c-916f-6c13fc4b69f9", decodedInvoice.invoiceUUID) + assertEquals(1000, decodedInvoice.amount) + assertEquals(1000000, decodedInvoice.expiration) + assertEquals(true, decodedInvoice.isSubjectToTravelRule) + assertEquals("0.3", decodedInvoice.umaVersion) + assertEquals(KycStatus.VERIFIED, decodedInvoice.kycStatus) + assertEquals("https://example.com/callback", decodedInvoice.callback) + assertEquals(InvoiceCurrency("USD", "US Dollar", "$", 2), decodedInvoice.receivingCurrency) + } + @Test fun `test create and parse payreq in receiving amount`() = runTest { val travelRuleInfo = "travel rule info" - val payreq = UmaProtocolHelper().getPayRequest( - receiverEncryptionPubKey = keys.publicKey, - sendingVaspPrivateKey = keys.privateKey, - receivingCurrencyCode = "USD", - amount = 100, - isAmountInReceivingCurrency = true, - payerIdentifier = "test@test.com", - payerKycStatus = KycStatus.VERIFIED, - utxoCallback = "https://example.com/utxo", - travelRuleInfo = "travel rule info", - travelRuleFormat = TravelRuleFormat("someFormat", "1.0"), - requestedPayeeData = createCounterPartyDataOptions( - "email" to true, - "name" to false, - "compliance" to true, - ), - receiverUmaVersion = "1.0", - ) + val payreq = + UmaProtocolHelper().getPayRequest( + receiverEncryptionPubKey = keys.publicKey, + sendingVaspPrivateKey = keys.privateKey, + receivingCurrencyCode = "USD", + amount = 100, + isAmountInReceivingCurrency = true, + payerIdentifier = "test@test.com", + payerKycStatus = KycStatus.VERIFIED, + utxoCallback = "https://example.com/utxo", + travelRuleInfo = "travel rule info", + travelRuleFormat = TravelRuleFormat("someFormat", "1.0"), + requestedPayeeData = + createCounterPartyDataOptions( + "email" to true, + "name" to false, + "compliance" to true, + ), + receiverUmaVersion = "1.0", + ) assertTrue(payreq is PayRequestV1) assertEquals("USD", payreq.receivingCurrencyCode()) assertEquals("USD", payreq.sendingCurrencyCode()) @@ -57,17 +140,18 @@ class UmaTests { @OptIn(ExperimentalStdlibApi::class) @Test fun `test create and parse payreq in msats`() = 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 = "1.0", - ) + 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 = "1.0", + ) assertTrue(payreq is PayRequestV1) assertNull(payreq.sendingCurrencyCode()) assertEquals("USD", payreq.receivingCurrencyCode()) @@ -82,17 +166,18 @@ class UmaTests { @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", - ) + 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()) @@ -112,7 +197,8 @@ class UmaTests { @Test fun `test serialization nulls`() = runTest { // Missing nodePubKey and encryptedTravelRuleInfo: - val jsonCompliancePayerData = """ + val jsonCompliancePayerData = + """ { "utxos": ["utxo1", "utxo2"], "kycStatus": "VERIFIED", @@ -122,7 +208,7 @@ class UmaTests { "signatureTimestamp": 1234567, "travelRuleFormat": null } - """.trimIndent() + """.trimIndent() val compliancePayerData = serialFormat.decodeFromString(jsonCompliancePayerData) assertEquals( @@ -139,4 +225,53 @@ class UmaTests { compliancePayerData, ) } + + private fun createInvoice(): Invoice { + val requiredPayerData = + mapOf( + "name" to CounterPartyDataOption(false), + "email" to CounterPartyDataOption(false), + "compliance" to CounterPartyDataOption(true), + ) + val invoiceCurrency = + InvoiceCurrency( + code = "USD", + name = "US Dollar", + symbol = "$", + decimals = 2, + ) + + return Invoice( + receiverUma = "\$foo@bar.com", + invoiceUUID = "c7c07fec-cf00-431c-916f-6c13fc4b69f9", + amount = 1000, + receivingCurrency = invoiceCurrency, + expiration = 1000000, + isSubjectToTravelRule = true, + requiredPayerData = requiredPayerData, + commentCharsAllowed = null, + senderUma = null, + invoiceLimit = null, + umaVersion = "0.3", + kycStatus = KycStatus.VERIFIED, + callback = "https://example.com/callback", + signature = "signature".toByteArray(), + ) + } + + private fun validateInvoice(preEncodedInvoice: Invoice, decodedInvoice: Invoice) { + assertEquals(preEncodedInvoice.receiverUma, decodedInvoice.receiverUma) + assertEquals(preEncodedInvoice.invoiceUUID, decodedInvoice.invoiceUUID) + assertEquals(preEncodedInvoice.amount, decodedInvoice.amount) + assertEquals(preEncodedInvoice.expiration, decodedInvoice.expiration) + assertEquals(preEncodedInvoice.isSubjectToTravelRule, decodedInvoice.isSubjectToTravelRule) + assertEquals(preEncodedInvoice.commentCharsAllowed, decodedInvoice.commentCharsAllowed) + assertEquals(preEncodedInvoice.senderUma, decodedInvoice.senderUma) + assertEquals(preEncodedInvoice.invoiceLimit, decodedInvoice.invoiceLimit) + assertEquals(preEncodedInvoice.umaVersion, decodedInvoice.umaVersion) + assertEquals(preEncodedInvoice.kycStatus, decodedInvoice.kycStatus) + assertEquals(preEncodedInvoice.callback, decodedInvoice.callback) + assertEquals(preEncodedInvoice.requiredPayerData, decodedInvoice.requiredPayerData) + assertEquals(preEncodedInvoice.receivingCurrency, decodedInvoice.receivingCurrency) + } } diff --git a/uma-sdk/src/jvmMain/resources/darwin-aarch64/libuniffi_uma_crypto.dylib b/uma-sdk/src/jvmMain/resources/darwin-aarch64/libuniffi_uma_crypto.dylib index 6dede14..4c2de17 100755 Binary files a/uma-sdk/src/jvmMain/resources/darwin-aarch64/libuniffi_uma_crypto.dylib and b/uma-sdk/src/jvmMain/resources/darwin-aarch64/libuniffi_uma_crypto.dylib differ diff --git a/uma-sdk/src/jvmMain/resources/darwin-x86-64/libuniffi_uma_crypto.dylib b/uma-sdk/src/jvmMain/resources/darwin-x86-64/libuniffi_uma_crypto.dylib index 3998353..2ad305b 100755 Binary files a/uma-sdk/src/jvmMain/resources/darwin-x86-64/libuniffi_uma_crypto.dylib and b/uma-sdk/src/jvmMain/resources/darwin-x86-64/libuniffi_uma_crypto.dylib differ diff --git a/uma-sdk/src/jvmMain/resources/linux-aarch64/libuniffi_uma_crypto.so b/uma-sdk/src/jvmMain/resources/linux-aarch64/libuniffi_uma_crypto.so index 1450f7a..b06d11a 100755 Binary files a/uma-sdk/src/jvmMain/resources/linux-aarch64/libuniffi_uma_crypto.so and b/uma-sdk/src/jvmMain/resources/linux-aarch64/libuniffi_uma_crypto.so differ diff --git a/uma-sdk/src/jvmMain/resources/linux-x86-64/libuniffi_uma_crypto.so b/uma-sdk/src/jvmMain/resources/linux-x86-64/libuniffi_uma_crypto.so index a9871a8..b2acbac 100755 Binary files a/uma-sdk/src/jvmMain/resources/linux-x86-64/libuniffi_uma_crypto.so and b/uma-sdk/src/jvmMain/resources/linux-x86-64/libuniffi_uma_crypto.so differ