diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt index 02f1929..8bc1ece 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt @@ -893,10 +893,7 @@ class UmaProtocolHelper @JvmOverloads constructor( return identifier.substring(atIndex + 1) } - fun verifyUmaInvoice( - invoice: Invoice, - pubKeyResponse: PubKeyResponse, - ): Boolean { + fun verifyUmaInvoice(invoice: Invoice, pubKeyResponse: PubKeyResponse): Boolean { return invoice.signature?.let { signature -> verifySignature( invoice.toSignablePayload(), @@ -909,11 +906,10 @@ class UmaProtocolHelper @JvmOverloads constructor( fun getInvoice( receiverUma: String, invoiceUUID: String, - amount: Int, + amount: Long, receivingCurrency: InvoiceCurrency, - expiration: Int, + expiration: Long, isSubjectToTravelRule: Boolean, - umaVersion: String, commentCharsAllowed: Int? = null, senderUma: String? = null, invoiceLimit: Int? = null, @@ -929,7 +925,7 @@ class UmaProtocolHelper @JvmOverloads constructor( receivingCurrency = receivingCurrency, expiration = expiration, isSubjectToTravelRule = isSubjectToTravelRule, - umaVersion = umaVersion, + umaVersion = UMA_VERSION_STRING, commentCharsAllowed = commentCharsAllowed, senderUma = senderUma, invoiceLimit = invoiceLimit, diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Invoice.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Invoice.kt index 5af91ed..e81c61d 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Invoice.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/Invoice.kt @@ -34,7 +34,7 @@ data class InvoiceCurrency( 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) + 3 -> decimals = bytes.getInt(offset.valueOffset(), length) } offset = offset.valueOffset() + length } @@ -73,11 +73,11 @@ data class Invoice( /** 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, + val amount: Long, /** The currency of the invoice */ val receivingCurrency: InvoiceCurrency, /** The unix timestamp the UMA invoice expires */ - val expiration: Int, + val expiration: Long, /** 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. */ @@ -141,12 +141,12 @@ data class Invoice( 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) + 2 -> ib.amount = bytes.getLong(offset.valueOffset(), length) 3 -> ib.receivingCurrency = bytes.getTLV(offset.valueOffset(), length, InvoiceCurrency::fromTLV) as InvoiceCurrency - 4 -> ib.expiration = bytes.getNumber(offset.valueOffset(), length) + 4 -> ib.expiration = bytes.getLong(offset.valueOffset(), length) 5 -> ib.isSubjectToTravelRule = bytes.getBoolean(offset.valueOffset()) 6 -> ib.requiredPayerData = @@ -159,9 +159,9 @@ data class Invoice( ).options 7 -> ib.umaVersion = bytes.getString(offset.valueOffset(), length) - 8 -> ib.commentCharsAllowed = bytes.getNumber(offset.valueOffset(), length) + 8 -> ib.commentCharsAllowed = bytes.getInt(offset.valueOffset(), length) 9 -> ib.senderUma = bytes.getString(offset.valueOffset(), length) - 10 -> ib.invoiceLimit = bytes.getNumber(offset.valueOffset(), length) + 10 -> ib.invoiceLimit = bytes.getInt(offset.valueOffset(), length) 11 -> ib.kycStatus = ( bytes.getByteCodeable( @@ -193,9 +193,9 @@ data class Invoice( class InvoiceBuilder { var receiverUma: String? = null var invoiceUUID: String? = null - var amount: Int? = null + var amount: Long? = null var receivingCurrency: InvoiceCurrency? = null - var expiration: Int? = null + var expiration: Long? = null var isSubjectToTravelRule: Boolean? = null var requiredPayerData: CounterPartyDataOptions? = null var umaVersion: String? = null diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/utils/TLVUtils.kt b/uma-sdk/src/commonMain/kotlin/me/uma/utils/TLVUtils.kt index 4de5f1d..43abf7a 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/utils/TLVUtils.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/utils/TLVUtils.kt @@ -41,6 +41,23 @@ fun MutableList.putNumber(tag: Int, value: Number?): MutableList { + 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()) + } + in Int.MIN_VALUE..Int.MAX_VALUE -> { + tlvBuffer(Int.SIZE_BYTES).putInt(value.toInt()) + } + else -> { + tlvBuffer(Long.SIZE_BYTES).putLong(value) + } + } + } + is Int -> { when (value) { in Byte.MIN_VALUE.toInt()..Byte.MAX_VALUE.toInt() -> { @@ -54,6 +71,7 @@ fun MutableList.putNumber(tag: Int, value: Number?): MutableList { when (value) { in Byte.MIN_VALUE..Byte.MAX_VALUE -> { @@ -62,12 +80,12 @@ fun MutableList.putNumber(tag: Int, value: Number?): MutableList 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() + }.array(), ) return this } @@ -128,12 +146,28 @@ fun MutableList.array(): ByteArray { return buffer.array() } -fun ByteArray.getNumber(offset: Int, length: Int): Int { +fun ByteArray.getInt(offset: Int, length: Int): Int { + return getNumber(offset, length).toInt() +} + +fun ByteArray.getLong(offset: Int, length: Int): Long { + return getNumber(offset, length).toLong() +} + +/** + * in Invoice's TLV, the numeric fields are stored in their smallest possible representation (ie, 9L would + * be stored as a single Byte) + * what this means is that when deserializing, we can't simply call buffer.getLong() for Long fields, + * as the encoded field may be as little as 1 byte, triggering a Buffer Underflow Exception. + * Instead, we read the value based on its byte length, and then case it to a Long or Int in a wrapper function + */ +private fun ByteArray.getNumber(offset: Int, length: Int): Number { val buffer = ByteBuffer.wrap(slice(offset.. this[offset].toInt() 2 -> buffer.getShort().toInt() 4 -> buffer.getInt() + 8 -> buffer.getLong() else -> this[offset].toInt() } } diff --git a/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt b/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt index 7cfe47a..37d0484 100644 --- a/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt +++ b/uma-sdk/src/commonTest/kotlin/me/uma/UmaTests.kt @@ -48,6 +48,15 @@ class UmaTests { validateInvoice(invoice, result) } + @Test + fun `correctly serialized timestamps in invoices`() = runTest { + val timestamp = System.currentTimeMillis() + val invoice = createInvoice(timestamp) + val serializedInvoice = serialFormat.encodeToString(invoice) + val result = serialFormat.decodeFromString(serializedInvoice) + assertEquals(result.expiration, timestamp) + } + @Test fun `deserializing an Invoice with missing required fields triggers error`() = runTest { val exception = @@ -88,7 +97,7 @@ class UmaTests { assertEquals("\$foo@bar.com", decodedInvoice.receiverUma) assertEquals("c7c07fec-cf00-431c-916f-6c13fc4b69f9", decodedInvoice.invoiceUUID) assertEquals(1000, decodedInvoice.amount) - assertEquals(1000000, decodedInvoice.expiration) + assertEquals(1000000L, decodedInvoice.expiration) assertEquals(true, decodedInvoice.isSubjectToTravelRule) assertEquals("0.3", decodedInvoice.umaVersion) assertEquals(KycStatus.VERIFIED, decodedInvoice.kycStatus) @@ -113,10 +122,9 @@ class UmaTests { commentCharsAllowed = null, senderUma = null, invoiceLimit = null, - umaVersion = "0.3", kycStatus = KycStatus.VERIFIED, callback = "https://example.com/callback", - privateSigningKey = keys.privateKey + privateSigningKey = keys.privateKey, ) assertTrue(UmaProtocolHelper().verifyUmaInvoice(invoice, PubKeyResponse(keys.publicKey, keys.publicKey))) } @@ -136,12 +144,11 @@ class UmaTests { 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, - ), + requestedPayeeData = createCounterPartyDataOptions( + "email" to true, + "name" to false, + "compliance" to true, + ), receiverUmaVersion = "1.0", ) assertTrue(payreq is PayRequestV1) @@ -251,7 +258,7 @@ class UmaTests { ) } - private fun createInvoice(): Invoice { + private fun createInvoice(timestamp: Long? = null): Invoice { val requiredPayerData = mapOf( "name" to CounterPartyDataOption(false), @@ -269,9 +276,9 @@ class UmaTests { return Invoice( receiverUma = "\$foo@bar.com", invoiceUUID = "c7c07fec-cf00-431c-916f-6c13fc4b69f9", - amount = 1000, + amount = 1000L, receivingCurrency = invoiceCurrency, - expiration = 1000000, + expiration = timestamp ?: 1000000L, isSubjectToTravelRule = true, requiredPayerData = requiredPayerData, commentCharsAllowed = null,