From d63b5c6f28e091ec7372277888ff4a3dfb7ed426 Mon Sep 17 00:00:00 2001 From: m724 Date: Sun, 2 Nov 2025 11:52:43 +0100 Subject: [PATCH] feat(endpoint): Transfer support + test improvements --- .../kotlin/eu/m724/nanorpc/NanoRpcClient.kt | 29 +++++++- .../kotlin/eu/m724/nanorpc/Validators.kt | 11 +++ .../model/request/AccountBalanceRequest.kt | 4 +- .../m724/nanorpc/model/request/SendRequest.kt | 24 +++++++ .../model/response/AccountBalanceResponse.kt | 14 +++- .../model/response/NodeVersionResponse.kt | 4 +- .../nanorpc/model/response/SendResponse.kt | 16 +++++ .../eu/m724/nanorpc/NanoRpcClientTest.kt | 52 ++++++++------ .../nanorpc/{TestUtils.kt => test/Infixes.kt} | 4 +- .../kotlin/eu/m724/nanorpc/test/Resources.kt | 70 +++++++++++++++++++ .../request/account_balance_request.json | 4 ++ .../resources/request/send_request.json | 8 +++ .../resources/request/version_request.json | 3 + .../response/account_balance_response.json | 4 ++ .../resources/response/send_response.json | 3 + .../resources/response/version_response.json | 10 +++ .../resources/test/test_resource.txt | 1 + 17 files changed, 229 insertions(+), 32 deletions(-) create mode 100644 src/nativeMain/kotlin/eu/m724/nanorpc/Validators.kt create mode 100644 src/nativeMain/kotlin/eu/m724/nanorpc/model/request/SendRequest.kt create mode 100644 src/nativeMain/kotlin/eu/m724/nanorpc/model/response/SendResponse.kt rename src/nativeTest/kotlin/eu/m724/nanorpc/{TestUtils.kt => test/Infixes.kt} (90%) create mode 100644 src/nativeTest/kotlin/eu/m724/nanorpc/test/Resources.kt create mode 100644 src/nativeTest/resources/request/account_balance_request.json create mode 100644 src/nativeTest/resources/request/send_request.json create mode 100644 src/nativeTest/resources/request/version_request.json create mode 100644 src/nativeTest/resources/response/account_balance_response.json create mode 100644 src/nativeTest/resources/response/send_response.json create mode 100644 src/nativeTest/resources/response/version_response.json create mode 100644 src/nativeTest/resources/test/test_resource.txt diff --git a/src/nativeMain/kotlin/eu/m724/nanorpc/NanoRpcClient.kt b/src/nativeMain/kotlin/eu/m724/nanorpc/NanoRpcClient.kt index e9b76b8..14253fe 100644 --- a/src/nativeMain/kotlin/eu/m724/nanorpc/NanoRpcClient.kt +++ b/src/nativeMain/kotlin/eu/m724/nanorpc/NanoRpcClient.kt @@ -1,9 +1,12 @@ package eu.m724.nanorpc +import eu.m724.nanorpc.model.NanoAmount import eu.m724.nanorpc.model.request.AccountBalanceRequest +import eu.m724.nanorpc.model.request.SendRequest import eu.m724.nanorpc.model.request.SimpleActionRequest import eu.m724.nanorpc.model.response.AccountBalanceResponse import eu.m724.nanorpc.model.response.NodeVersionResponse +import eu.m724.nanorpc.model.response.SendResponse import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.HttpClientEngine @@ -20,6 +23,7 @@ import io.ktor.http.Url import io.ktor.http.contentType import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json +import kotlin.random.Random /** * A client for interacting with a Nano cryptocurrency RPC node. This class facilitates communication with the @@ -78,7 +82,7 @@ class NanoRpcClient internal constructor( account: String ): Result { return postJsonForResult( - AccountBalanceRequest(account = account) + AccountBalanceRequest(account) ) } @@ -94,6 +98,29 @@ class NanoRpcClient internal constructor( ) } + /** + * Transfers a specified amount from a wallet to a destination address. + * + * @param walletId The ID of the wallet from which the transfer will be made. + * @param sourceAddress The source address within the wallet from which the amount will be deducted. + * @param destinationAddress The destination address to which the amount will be sent. + * @param amount The amount of Nano to be transferred, represented as a [NanoAmount]. + * @param id An optional unique identifier for the transaction. Defaults to a randomly generated value. + * @return A [Result] containing a [SendResponse], which includes details of the resulting block, + * or an error if the transfer fails. + */ + suspend fun transferAmount( + walletId: String, + sourceAddress: String, + destinationAddress: String, + amount: NanoAmount, + id: String = Random.nextLong().toHexString() + ): Result { + return postJsonForResult( + SendRequest(walletId, sourceAddress, destinationAddress, amount, id) + ) + } + override fun close() { httpClient.close() } diff --git a/src/nativeMain/kotlin/eu/m724/nanorpc/Validators.kt b/src/nativeMain/kotlin/eu/m724/nanorpc/Validators.kt new file mode 100644 index 0000000..0f8f4af --- /dev/null +++ b/src/nativeMain/kotlin/eu/m724/nanorpc/Validators.kt @@ -0,0 +1,11 @@ +package eu.m724.nanorpc + +private val addressRegex = Regex("^(nano|xrb|dn)_[13][13456789abcdefghijkmnopqrstuwxyz]{59}$") + +fun String.isValidWalletId(): Boolean { + return this.hexToByteArray().size == 32 +} + +fun String.isValidAddress(): Boolean { + return addressRegex.matches(this) +} \ No newline at end of file diff --git a/src/nativeMain/kotlin/eu/m724/nanorpc/model/request/AccountBalanceRequest.kt b/src/nativeMain/kotlin/eu/m724/nanorpc/model/request/AccountBalanceRequest.kt index 3b91603..9487e98 100644 --- a/src/nativeMain/kotlin/eu/m724/nanorpc/model/request/AccountBalanceRequest.kt +++ b/src/nativeMain/kotlin/eu/m724/nanorpc/model/request/AccountBalanceRequest.kt @@ -4,8 +4,8 @@ import kotlinx.serialization.Serializable @Serializable data class AccountBalanceRequest( - val action: String = "account_balance", - val account: String + val account: String, + val action: String = "account_balance" ) { init { require(action == "account_balance") { "action must be account_balance" } diff --git a/src/nativeMain/kotlin/eu/m724/nanorpc/model/request/SendRequest.kt b/src/nativeMain/kotlin/eu/m724/nanorpc/model/request/SendRequest.kt new file mode 100644 index 0000000..c480282 --- /dev/null +++ b/src/nativeMain/kotlin/eu/m724/nanorpc/model/request/SendRequest.kt @@ -0,0 +1,24 @@ +package eu.m724.nanorpc.model.request + +import eu.m724.nanorpc.isValidAddress +import eu.m724.nanorpc.isValidWalletId +import eu.m724.nanorpc.model.NanoAmount +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SendRequest( + @SerialName("wallet") val walletId: String, + @SerialName("source") val sourceAddress: String, + @SerialName("destination") val destinationAddress: String, + val amount: NanoAmount, + val id: String, + val action: String = "send" +) { + init { + require(walletId.isValidWalletId()) { "Wallet ID must be a 32-byte hex string" } + require(sourceAddress.isValidAddress()) { "Source address must be a valid address" } + require(destinationAddress.isValidAddress()) { "Destination address must be a valid address" } + require(amount.raw > 0) { "Amount must be greater than zero" } + } +} \ No newline at end of file diff --git a/src/nativeMain/kotlin/eu/m724/nanorpc/model/response/AccountBalanceResponse.kt b/src/nativeMain/kotlin/eu/m724/nanorpc/model/response/AccountBalanceResponse.kt index c096f8f..357ac81 100644 --- a/src/nativeMain/kotlin/eu/m724/nanorpc/model/response/AccountBalanceResponse.kt +++ b/src/nativeMain/kotlin/eu/m724/nanorpc/model/response/AccountBalanceResponse.kt @@ -3,8 +3,20 @@ package eu.m724.nanorpc.model.response import eu.m724.nanorpc.model.NanoAmount import kotlinx.serialization.Serializable +/** + * Represents the response containing the balance and receivable amounts for a Nano account. + * + * This data class encapsulates the balance and receivable amounts for a Nano account. + * + * @property balance The confirmed account balance as a [NanoAmount]. + * @property receivable The available but unconfirmed balance as a [NanoAmount]. + * @property pending The pending balance, deprecated in favor and equivalent of [receivable]. + */ @Serializable data class AccountBalanceResponse( val balance: NanoAmount, - val receivable: NanoAmount + val receivable: NanoAmount, + + @Deprecated(message = "Use receivable instead.") + val pending: NanoAmount = NanoAmount.ZERO ) \ No newline at end of file diff --git a/src/nativeMain/kotlin/eu/m724/nanorpc/model/response/NodeVersionResponse.kt b/src/nativeMain/kotlin/eu/m724/nanorpc/model/response/NodeVersionResponse.kt index a21cc7e..d71914c 100644 --- a/src/nativeMain/kotlin/eu/m724/nanorpc/model/response/NodeVersionResponse.kt +++ b/src/nativeMain/kotlin/eu/m724/nanorpc/model/response/NodeVersionResponse.kt @@ -7,9 +7,7 @@ import kotlinx.serialization.Serializable * Represents the response received from querying the version of a Nano node. * * This data class encapsulates versioning and identifying information provided - * by the Nano node, including the RPC version, store version, protocol version, - * vendor details, network name, and build metadata. These properties help - * determine the compatibility and configuration of the node in the Nano network. + * by the Nano node. * * @property rpcVersion The version of the RPC protocol supported by the node. * @property storeVersion The version of the node's storage layer. diff --git a/src/nativeMain/kotlin/eu/m724/nanorpc/model/response/SendResponse.kt b/src/nativeMain/kotlin/eu/m724/nanorpc/model/response/SendResponse.kt new file mode 100644 index 0000000..543b80d --- /dev/null +++ b/src/nativeMain/kotlin/eu/m724/nanorpc/model/response/SendResponse.kt @@ -0,0 +1,16 @@ +package eu.m724.nanorpc.model.response + +import kotlinx.serialization.Serializable + +/** + * Represents the response for a successful Nano transfer. + * + * This data class encapsulates information about the resulting block + * created by the transfer operation. + * + * @property block The hash of the block representing the transfer transaction. + */ +@Serializable +data class SendResponse( + val block: String +) \ No newline at end of file diff --git a/src/nativeTest/kotlin/eu/m724/nanorpc/NanoRpcClientTest.kt b/src/nativeTest/kotlin/eu/m724/nanorpc/NanoRpcClientTest.kt index e3e1fc8..20a6669 100644 --- a/src/nativeTest/kotlin/eu/m724/nanorpc/NanoRpcClientTest.kt +++ b/src/nativeTest/kotlin/eu/m724/nanorpc/NanoRpcClientTest.kt @@ -1,10 +1,13 @@ package eu.m724.nanorpc -import eu.m724.nanorpc.model.NanoAmount import eu.m724.nanorpc.model.request.AccountBalanceRequest +import eu.m724.nanorpc.model.request.SendRequest import eu.m724.nanorpc.model.request.SimpleActionRequest import eu.m724.nanorpc.model.response.AccountBalanceResponse import eu.m724.nanorpc.model.response.NodeVersionResponse +import eu.m724.nanorpc.model.response.SendResponse +import eu.m724.nanorpc.test.getResource +import eu.m724.nanorpc.test.shouldBe import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.ServerResponseException @@ -27,16 +30,8 @@ class NanoRpcClientTest { @Test fun `getNodeVersion should return node version information on success`() { runBlocking { - val expectedResponse = NodeVersionResponse( - rpcVersion = 1, - storeVersion = 24, - protocolVersion = 21, - nodeVendor = "Nano V28.2", - storeVendor = "LMDB 0.9.70", - network = "live", - networkIdentifier = "991CF190094C00F0B68E2E5F75F6BEE95A2E0BD93CEAA4A6734DB9F19B728948", - buildInfo = "0d8eea4 \"GNU C++ version \" \"11.4.0\" \"BOOST 108600\" BUILT \"Aug 20 2025\"" - ) + val expectedRequest = Json.decodeFromString(getResource("request/version_request.json")) + val expectedResponse = Json.decodeFromString(getResource("response/version_response.json")) val (client, mockEngine) = createMockClient(Json.encodeToString(expectedResponse)) @@ -47,7 +42,7 @@ class NanoRpcClientTest { val request = mockEngine.requestHistory.single() request.url shouldBe Url("http://localhost:7075") request.method shouldBe HttpMethod.Post - request.body shouldBeJson SimpleActionRequest("version") + request.body shouldBeJson expectedRequest } } @@ -74,21 +69,34 @@ class NanoRpcClientTest { } } - // What else? ...everything? - @Test fun `getAccountBalance should return account balance on success`() { runBlocking { - val expectedRequest = - AccountBalanceRequest(account = BURN_ADDRESS) - val expectedResponse = AccountBalanceResponse( - balance = NanoAmount.MAX, - receivable = NanoAmount.MAX - ) + val expectedRequest = Json.decodeFromString(getResource("request/account_balance_request.json")) + val expectedResponse = Json.decodeFromString(getResource("response/account_balance_response.json")) val (client, mockEngine) = createMockClient(Json.encodeToString(expectedResponse)) - val balance = client.getAccountBalance(BURN_ADDRESS).getOrThrow() + val balance = client.getAccountBalance(expectedRequest.account).getOrThrow() + + balance shouldBe expectedResponse + + val request = mockEngine.requestHistory.single() + request.url shouldBe Url("http://localhost:7075") + request.method shouldBe HttpMethod.Post + request.body shouldBeJson expectedRequest + } + } + + @Test + fun `transferAmount should return block on success`() { + runBlocking { + val expectedRequest = Json.decodeFromString(getResource("request/send_request.json")) + val expectedResponse = Json.decodeFromString(getResource("response/send_response.json")) + + val (client, mockEngine) = createMockClient(getResource("response/send_response.json")) + + val balance = client.transferAmount(expectedRequest.walletId, expectedRequest.sourceAddress, expectedRequest.destinationAddress, expectedRequest.amount, expectedRequest.id).getOrThrow() balance shouldBe expectedResponse @@ -100,8 +108,6 @@ class NanoRpcClientTest { } } -const val BURN_ADDRESS = "nano_1111111111111111111111111111111111111111111111111111hifc8npp" - private inline infix fun OutgoingContent.shouldBeJson(expected: T) { assertIs(this) assertEquals(expected, Json.decodeFromString(this.text)) diff --git a/src/nativeTest/kotlin/eu/m724/nanorpc/TestUtils.kt b/src/nativeTest/kotlin/eu/m724/nanorpc/test/Infixes.kt similarity index 90% rename from src/nativeTest/kotlin/eu/m724/nanorpc/TestUtils.kt rename to src/nativeTest/kotlin/eu/m724/nanorpc/test/Infixes.kt index b3a5093..4becf24 100644 --- a/src/nativeTest/kotlin/eu/m724/nanorpc/TestUtils.kt +++ b/src/nativeTest/kotlin/eu/m724/nanorpc/test/Infixes.kt @@ -1,4 +1,4 @@ -package eu.m724.nanorpc +package eu.m724.nanorpc.test import kotlin.test.assertEquals @@ -10,4 +10,4 @@ import kotlin.test.assertEquals */ infix fun Any.shouldBe(other: Any) { // No, does nothing assertEquals(other, this) -} +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/eu/m724/nanorpc/test/Resources.kt b/src/nativeTest/kotlin/eu/m724/nanorpc/test/Resources.kt new file mode 100644 index 0000000..a054318 --- /dev/null +++ b/src/nativeTest/kotlin/eu/m724/nanorpc/test/Resources.kt @@ -0,0 +1,70 @@ +package eu.m724.nanorpc.test + +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import kotlinx.io.Source +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readString +import kotlinx.io.writeString +import kotlin.test.Test +import kotlin.test.assertEquals + + +private val RESOURCE_ROOT_PATH = Path("src/nativeTest/resources/") + +fun getResourcePath(resource: String): Path = + Path(RESOURCE_ROOT_PATH, resource) + +/** + * Retrieves a raw source for the specified resource. + * + * @param resource The relative path to the resource within the `resources` directory. + * @return A []RawSource] instance for the specified resource. + */ +fun getResourceSource(resource: String): RawSource { + return SystemFileSystem.source(getResourcePath(resource)) +} + +/** + * Reads the content of a specified resource file as a String. + * + * @param resource The path to the resource file, relative to the `resources` directory. + * @return The content of the resource file as a String. + */ +fun getResource(resource: String): String { + return getResourceSource(resource).buffered().use { source -> + source.readString() + } +} + +/** + * Provides a buffered Source with the given String content for use within the provided block. + * + * @param content The String contents to be written to the Source. + * @param block A function block that operates on the provided Source. + */ +fun withMockSource(content: String, block: (Source) -> Unit) { + Buffer().use { buffer -> + buffer.writeString(content) + + (buffer as RawSource).buffered().use { source -> + block(source) + } + } +} + +class ResourcesTest { + @Test + fun `getResource should return the content of the test resource`() { + getResource("test/test_resource.txt") shouldBe "This is a test resource" + } + + @Test + fun `withMockSource should provide a Source with the given String content`() { + withMockSource("Content 123") { source -> + assertEquals("Content 123", source.readString()) + } + } +} \ No newline at end of file diff --git a/src/nativeTest/resources/request/account_balance_request.json b/src/nativeTest/resources/request/account_balance_request.json new file mode 100644 index 0000000..3039b53 --- /dev/null +++ b/src/nativeTest/resources/request/account_balance_request.json @@ -0,0 +1,4 @@ +{ + "action": "account_balance", + "account": "nano_1111111111111111111111111111111111111111111111111111hifc8npp" +} \ No newline at end of file diff --git a/src/nativeTest/resources/request/send_request.json b/src/nativeTest/resources/request/send_request.json new file mode 100644 index 0000000..bdda29d --- /dev/null +++ b/src/nativeTest/resources/request/send_request.json @@ -0,0 +1,8 @@ +{ + "action": "send", + "wallet": "000D1BAEC8EC208142C99059B393051BAC8380F9B5A2E6B2489A277D81789F3F", + "source": "nano_1111111111111111111111111111111111111111111111111111hifc8npp", + "destination": "nano_1111111111111111111111111111111111111111111111111111hifc8npp", + "amount": "1000000", + "id": "7081e2b8fec9146e" +} diff --git a/src/nativeTest/resources/request/version_request.json b/src/nativeTest/resources/request/version_request.json new file mode 100644 index 0000000..8b3ac8a --- /dev/null +++ b/src/nativeTest/resources/request/version_request.json @@ -0,0 +1,3 @@ +{ + "action": "version" +} diff --git a/src/nativeTest/resources/response/account_balance_response.json b/src/nativeTest/resources/response/account_balance_response.json new file mode 100644 index 0000000..7a58d25 --- /dev/null +++ b/src/nativeTest/resources/response/account_balance_response.json @@ -0,0 +1,4 @@ +{ + "balance": "340282366920938463463374607431768211455", + "receivable": "340282366920938463463374607431768211455" +} \ No newline at end of file diff --git a/src/nativeTest/resources/response/send_response.json b/src/nativeTest/resources/response/send_response.json new file mode 100644 index 0000000..b6c7c69 --- /dev/null +++ b/src/nativeTest/resources/response/send_response.json @@ -0,0 +1,3 @@ +{ + "block": "000D1BAEC8EC208142C99059B393051BAC8380F9B5A2E6B2489A277D81789F3F" +} \ No newline at end of file diff --git a/src/nativeTest/resources/response/version_response.json b/src/nativeTest/resources/response/version_response.json new file mode 100644 index 0000000..ed9bb92 --- /dev/null +++ b/src/nativeTest/resources/response/version_response.json @@ -0,0 +1,10 @@ +{ + "rpc_version": "1", + "store_version": "24", + "protocol_version": "21", + "node_vendor": "Nano V28.2", + "store_vendor": "LMDB 0.9.70", + "network": "live", + "network_identifier": "991CF190094C00F0B68E2E5F75F6BEE95A2E0BD93CEAA4A6734DB9F19B728948", + "build_info": "0d8eea4 \"GNU C++ version \" \"11.4.0\" \"BOOST 108600\" BUILT \"Aug 20 2025\"" +} \ No newline at end of file diff --git a/src/nativeTest/resources/test/test_resource.txt b/src/nativeTest/resources/test/test_resource.txt new file mode 100644 index 0000000..67d64bc --- /dev/null +++ b/src/nativeTest/resources/test/test_resource.txt @@ -0,0 +1 @@ +This is a test resource \ No newline at end of file