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