feat(endpoint): Transfer support
+ test improvements
This commit is contained in:
parent
78e3d21ea2
commit
d63b5c6f28
17 changed files with 229 additions and 32 deletions
|
|
@ -1,9 +1,12 @@
|
||||||
package eu.m724.nanorpc
|
package eu.m724.nanorpc
|
||||||
|
|
||||||
|
import eu.m724.nanorpc.model.NanoAmount
|
||||||
import eu.m724.nanorpc.model.request.AccountBalanceRequest
|
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.request.SimpleActionRequest
|
||||||
import eu.m724.nanorpc.model.response.AccountBalanceResponse
|
import eu.m724.nanorpc.model.response.AccountBalanceResponse
|
||||||
import eu.m724.nanorpc.model.response.NodeVersionResponse
|
import eu.m724.nanorpc.model.response.NodeVersionResponse
|
||||||
|
import eu.m724.nanorpc.model.response.SendResponse
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.engine.HttpClientEngine
|
import io.ktor.client.engine.HttpClientEngine
|
||||||
|
|
@ -20,6 +23,7 @@ import io.ktor.http.Url
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import io.ktor.http.isSuccess
|
import io.ktor.http.isSuccess
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
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
|
* 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
|
account: String
|
||||||
): Result<AccountBalanceResponse> {
|
): Result<AccountBalanceResponse> {
|
||||||
return postJsonForResult(
|
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() {
|
override fun close() {
|
||||||
httpClient.close()
|
httpClient.close()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
src/nativeMain/kotlin/eu/m724/nanorpc/Validators.kt
Normal file
11
src/nativeMain/kotlin/eu/m724/nanorpc/Validators.kt
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,8 @@ import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AccountBalanceRequest(
|
data class AccountBalanceRequest(
|
||||||
val action: String = "account_balance",
|
val account: String,
|
||||||
val account: String
|
val action: String = "account_balance"
|
||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
require(action == "account_balance") { "action must be account_balance" }
|
require(action == "account_balance") { "action must be account_balance" }
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,20 @@ package eu.m724.nanorpc.model.response
|
||||||
import eu.m724.nanorpc.model.NanoAmount
|
import eu.m724.nanorpc.model.NanoAmount
|
||||||
import kotlinx.serialization.Serializable
|
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
|
@Serializable
|
||||||
data class AccountBalanceResponse(
|
data class AccountBalanceResponse(
|
||||||
val balance: NanoAmount,
|
val balance: NanoAmount,
|
||||||
val receivable: NanoAmount
|
val receivable: NanoAmount,
|
||||||
|
|
||||||
|
@Deprecated(message = "Use receivable instead.")
|
||||||
|
val pending: NanoAmount = NanoAmount.ZERO
|
||||||
)
|
)
|
||||||
|
|
@ -7,9 +7,7 @@ import kotlinx.serialization.Serializable
|
||||||
* Represents the response received from querying the version of a Nano node.
|
* Represents the response received from querying the version of a Nano node.
|
||||||
*
|
*
|
||||||
* This data class encapsulates versioning and identifying information provided
|
* This data class encapsulates versioning and identifying information provided
|
||||||
* by the Nano node, including the RPC version, store version, protocol version,
|
* by the Nano node.
|
||||||
* vendor details, network name, and build metadata. These properties help
|
|
||||||
* determine the compatibility and configuration of the node in the Nano network.
|
|
||||||
*
|
*
|
||||||
* @property rpcVersion The version of the RPC protocol supported by the node.
|
* @property rpcVersion The version of the RPC protocol supported by the node.
|
||||||
* @property storeVersion The version of the node's storage layer.
|
* @property storeVersion The version of the node's storage layer.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package eu.m724.nanorpc
|
package eu.m724.nanorpc
|
||||||
|
|
||||||
import eu.m724.nanorpc.model.NanoAmount
|
|
||||||
import eu.m724.nanorpc.model.request.AccountBalanceRequest
|
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.request.SimpleActionRequest
|
||||||
import eu.m724.nanorpc.model.response.AccountBalanceResponse
|
import eu.m724.nanorpc.model.response.AccountBalanceResponse
|
||||||
import eu.m724.nanorpc.model.response.NodeVersionResponse
|
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.MockEngine
|
||||||
import io.ktor.client.engine.mock.respond
|
import io.ktor.client.engine.mock.respond
|
||||||
import io.ktor.client.plugins.ServerResponseException
|
import io.ktor.client.plugins.ServerResponseException
|
||||||
|
|
@ -27,16 +30,8 @@ class NanoRpcClientTest {
|
||||||
@Test
|
@Test
|
||||||
fun `getNodeVersion should return node version information on success`() {
|
fun `getNodeVersion should return node version information on success`() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val expectedResponse = NodeVersionResponse(
|
val expectedRequest = Json.decodeFromString<SimpleActionRequest>(getResource("request/version_request.json"))
|
||||||
rpcVersion = 1,
|
val expectedResponse = Json.decodeFromString<NodeVersionResponse>(getResource("response/version_response.json"))
|
||||||
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 (client, mockEngine) = createMockClient(Json.encodeToString(expectedResponse))
|
val (client, mockEngine) = createMockClient(Json.encodeToString(expectedResponse))
|
||||||
|
|
||||||
|
|
@ -47,7 +42,7 @@ class NanoRpcClientTest {
|
||||||
val request = mockEngine.requestHistory.single()
|
val request = mockEngine.requestHistory.single()
|
||||||
request.url shouldBe Url("http://localhost:7075")
|
request.url shouldBe Url("http://localhost:7075")
|
||||||
request.method shouldBe HttpMethod.Post
|
request.method shouldBe HttpMethod.Post
|
||||||
request.body shouldBeJson SimpleActionRequest("version")
|
request.body shouldBeJson expectedRequest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,21 +69,34 @@ class NanoRpcClientTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// What else? ...everything?
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getAccountBalance should return account balance on success`() {
|
fun `getAccountBalance should return account balance on success`() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val expectedRequest =
|
val expectedRequest = Json.decodeFromString<AccountBalanceRequest>(getResource("request/account_balance_request.json"))
|
||||||
AccountBalanceRequest(account = BURN_ADDRESS)
|
val expectedResponse = Json.decodeFromString<AccountBalanceResponse>(getResource("response/account_balance_response.json"))
|
||||||
val expectedResponse = AccountBalanceResponse(
|
|
||||||
balance = NanoAmount.MAX,
|
|
||||||
receivable = NanoAmount.MAX
|
|
||||||
)
|
|
||||||
|
|
||||||
val (client, mockEngine) = createMockClient(Json.encodeToString(expectedResponse))
|
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
|
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) {
|
private inline infix fun <reified T> OutgoingContent.shouldBeJson(expected: T) {
|
||||||
assertIs<TextContent>(this)
|
assertIs<TextContent>(this)
|
||||||
assertEquals(expected, Json.decodeFromString<T>(this.text))
|
assertEquals(expected, Json.decodeFromString<T>(this.text))
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package eu.m724.nanorpc
|
package eu.m724.nanorpc.test
|
||||||
|
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
|
@ -10,4 +10,4 @@ import kotlin.test.assertEquals
|
||||||
*/
|
*/
|
||||||
infix fun Any.shouldBe(other: Any) { // No, <T> does nothing
|
infix fun Any.shouldBe(other: Any) { // No, <T> does nothing
|
||||||
assertEquals(other, this)
|
assertEquals(other, this)
|
||||||
}
|
}
|
||||||
70
src/nativeTest/kotlin/eu/m724/nanorpc/test/Resources.kt
Normal file
70
src/nativeTest/kotlin/eu/m724/nanorpc/test/Resources.kt
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"action": "account_balance",
|
||||||
|
"account": "nano_1111111111111111111111111111111111111111111111111111hifc8npp"
|
||||||
|
}
|
||||||
8
src/nativeTest/resources/request/send_request.json
Normal file
8
src/nativeTest/resources/request/send_request.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"action": "send",
|
||||||
|
"wallet": "000D1BAEC8EC208142C99059B393051BAC8380F9B5A2E6B2489A277D81789F3F",
|
||||||
|
"source": "nano_1111111111111111111111111111111111111111111111111111hifc8npp",
|
||||||
|
"destination": "nano_1111111111111111111111111111111111111111111111111111hifc8npp",
|
||||||
|
"amount": "1000000",
|
||||||
|
"id": "7081e2b8fec9146e"
|
||||||
|
}
|
||||||
3
src/nativeTest/resources/request/version_request.json
Normal file
3
src/nativeTest/resources/request/version_request.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"action": "version"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"balance": "340282366920938463463374607431768211455",
|
||||||
|
"receivable": "340282366920938463463374607431768211455"
|
||||||
|
}
|
||||||
3
src/nativeTest/resources/response/send_response.json
Normal file
3
src/nativeTest/resources/response/send_response.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"block": "000D1BAEC8EC208142C99059B393051BAC8380F9B5A2E6B2489A277D81789F3F"
|
||||||
|
}
|
||||||
10
src/nativeTest/resources/response/version_response.json
Normal file
10
src/nativeTest/resources/response/version_response.json
Normal 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\""
|
||||||
|
}
|
||||||
1
src/nativeTest/resources/test/test_resource.txt
Normal file
1
src/nativeTest/resources/test/test_resource.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
This is a test resource
|
||||||
Loading…
Add table
Add a link
Reference in a new issue