feat(endpoint): Transfer support

+ test improvements
This commit is contained in:
m724 2025-11-02 11:52:43 +01:00
commit d63b5c6f28
Signed by: m724
GPG key ID: A02E6E67AB961189
17 changed files with 229 additions and 32 deletions

View file

@ -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<AccountBalanceResponse> {
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<SendResponse> {
return postJsonForResult(
SendRequest(walletId, sourceAddress, destinationAddress, amount, id)
)
}
override fun close() {
httpClient.close()
}

View file

@ -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)
}

View file

@ -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" }

View file

@ -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" }
}
}

View file

@ -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
)

View file

@ -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.

View file

@ -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
)

View file

@ -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<SimpleActionRequest>(getResource("request/version_request.json"))
val expectedResponse = Json.decodeFromString<NodeVersionResponse>(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<AccountBalanceRequest>(getResource("request/account_balance_request.json"))
val expectedResponse = Json.decodeFromString<AccountBalanceResponse>(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<SendRequest>(getResource("request/send_request.json"))
val expectedResponse = Json.decodeFromString<SendResponse>(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 <reified T> OutgoingContent.shouldBeJson(expected: T) {
assertIs<TextContent>(this)
assertEquals(expected, Json.decodeFromString<T>(this.text))

View file

@ -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, <T> does nothing
assertEquals(other, this)
}
}

View file

@ -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())
}
}
}

View file

@ -0,0 +1,4 @@
{
"action": "account_balance",
"account": "nano_1111111111111111111111111111111111111111111111111111hifc8npp"
}

View file

@ -0,0 +1,8 @@
{
"action": "send",
"wallet": "000D1BAEC8EC208142C99059B393051BAC8380F9B5A2E6B2489A277D81789F3F",
"source": "nano_1111111111111111111111111111111111111111111111111111hifc8npp",
"destination": "nano_1111111111111111111111111111111111111111111111111111hifc8npp",
"amount": "1000000",
"id": "7081e2b8fec9146e"
}

View file

@ -0,0 +1,3 @@
{
"action": "version"
}

View file

@ -0,0 +1,4 @@
{
"balance": "340282366920938463463374607431768211455",
"receivable": "340282366920938463463374607431768211455"
}

View file

@ -0,0 +1,3 @@
{
"block": "000D1BAEC8EC208142C99059B393051BAC8380F9B5A2E6B2489A277D81789F3F"
}

View file

@ -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\""
}

View file

@ -0,0 +1 @@
This is a test resource