Merge branch 'feat/wallet-chain-client' into 'main'

Read a balance and a transaction history from a chain node (Android)

See merge request paulhitt/flamenet-messenger-android!47
This commit was merged in pull request #74.
This commit is contained in:
Paul Hitt
2026-09-04 20:49:42 +00:00
3 changed files with 341 additions and 1 deletions
+15 -1
View File
@@ -343,7 +343,7 @@ the messenger rather than staying separate apps.
| Wallet engine: address derivation and signing | ✅ BIP-44 path, Base58 address, and the engine's own RFC 8032 signs — no API-33 floor |
| Wallet: BIP-39 seed, create and restore | 🟡 engine done — create, import, restore, sealed at rest; no screen yet |
| Wallet: fresh receive address per receive | ⚪ |
| Wallet: balance and transactions from a node | ⚪ |
| Wallet: balance and transactions from a node | 🟡 `ChainRpc` reads both; no screen yet |
⚠️ Nothing above the engine exists on Android yet. This is the largest
remaining gap and it is a build, not a port of a screen.
@@ -398,6 +398,20 @@ plainly) — `signOut(erasingDevice = true)` and `deleteAccount` do not. That is
what a recovery phrase is for, and it has to be said at the moment the phrase is
shown rather than discovered afterwards.
⚠️ **The node is somebody else's server and is trusted with nothing.** It is
asked what a public address holds and handed transactions that are already
signed; it never sees a key. What it can do is lie about a balance or withhold a
history, so no answer is treated as authoritative beyond "this is what that node
said". And which node somebody points at is a privacy decision the app has to
state: asking a public endpoint for an address's balance ties that address to
this device's IP in somebody's logs.
🔴 **A JSON-RPC refusal arrives with HTTP 200.** A client checking only the
status code carries on with a body that has no result, fails somewhere less
obvious, and throws the node's own explanation away on the route — the
difference between "insufficient funds for rent exemption" on screen and a parse
error nobody can act on.
⚠️ **A Solana address has no checksum, and `looksLikeAddress` says "well
formed", never "correct".** A single mistyped character that still decodes to 32
bytes is a perfectly valid address nobody holds the key for; money sent there is
@@ -0,0 +1,137 @@
package io.flamenet.client
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
/**
* Talking to a chain node, for the wallet.
*
* 🔴 **The node is somebody else's server and it is not trusted with
* anything.** It is asked what a public address holds and handed transactions
* that are already signed; it never sees a key, and it cannot move money. What
* it CAN do is lie about a balance or withhold a history, so nothing here
* treats an answer as authoritative beyond "this is what that node said".
*
* ⚠️ **Which node somebody points at is a privacy decision, and the app has to
* say so.** Asking a public RPC endpoint for the balance of an address ties
* that address to this device's IP, on somebody else's logs. Running your own
* node is the only answer that removes it; the default exists so the wallet
* works at all, not because it is private.
*/
class ChainRpc(
val endpoint: String,
private val http: OkHttpClient = OkHttpClient(),
) {
sealed class Failure(message: String) : Exception(message) {
/** The transport refused before any JSON-RPC happened. */
class Http(val code: Int) : Failure("The node answered with $code.")
/**
* The node's own words. It knows why it refused and we do not, so its
* message is carried rather than replaced with a guess.
*/
class Node(val detail: String) : Failure(detail)
class Malformed(val what: String) :
Failure("That node's answer wasn't something this app understands.")
}
// ---- Wire ------------------------------------------------------------
private fun call(method: String, params: JSONArray): Any {
val body = JSONObject()
.put("jsonrpc", "2.0")
.put("id", 1)
.put("method", method)
.put("params", params)
.toString()
.toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(endpoint).post(body).build()
val text = http.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw Failure.Http(response.code)
response.body?.string().orEmpty()
}
val json = runCatching { JSONObject(text) }.getOrNull()
?: throw Failure.Malformed("not an object")
// 🔴 **A JSON-RPC error arrives with HTTP 200.** Reading only the status
// code takes a refusal for an answer, and then something fails further
// down with the node's own explanation already thrown away — which is
// the difference between "insufficient funds for rent" on screen and a
// parse error nobody can act on.
json.optJSONObject("error")?.let { error ->
throw Failure.Node(error.optString("message").ifEmpty { "The node refused." })
}
if (json.isNull("result")) throw Failure.Malformed("no result")
return json.get("result")
}
// ---- Reading ---------------------------------------------------------
/**
* The balance in lamports — the chain's smallest unit.
*
* ⚠️ Lamports, not SOL, all the way to the edge of the UI. Converting early
* means carrying a fractional value through code that compares and
* subtracts, and a rounding error in a balance is money.
*/
fun balance(address: String): Long {
val result = call("getBalance", JSONArray().put(address))
val wrapper = result as? JSONObject ?: throw Failure.Malformed("balance")
if (!wrapper.has("value")) throw Failure.Malformed("balance")
return wrapper.getLong("value")
}
/** What has happened to an address, newest first. */
fun history(address: String, limit: Int = 25): List<Entry> {
val result = call(
"getSignaturesForAddress",
JSONArray().put(address).put(JSONObject().put("limit", limit)),
)
val rows = result as? JSONArray ?: throw Failure.Malformed("history")
return (0 until rows.length()).mapNotNull { Entry.from(rows.optJSONObject(it)) }
}
/**
* One line of an address's history.
*
* 🔴 **A FAILED transaction still appears in an address's history.** Not
* showing that it failed would be the worst kind of wrong: somebody would
* believe a payment went through.
*/
data class Entry(
val signature: String,
val slot: Long,
/**
* Absent while a transaction is very new — the node has it but has not
* dated it yet. Null means "just now", never 1970.
*/
val blockTimeSeconds: Long?,
val failed: Boolean,
) {
companion object {
fun from(json: JSONObject?): Entry? {
val signature = json?.optString("signature")?.takeIf { it.isNotEmpty() }
?: return null
return Entry(
signature = signature,
slot = json.optLong("slot", 0),
blockTimeSeconds = if (json.isNull("blockTime")) null
else json.optLong("blockTime"),
// ⚠️ `err` is an object or an array depending on the
// failure, and `null` when there was none. Testing for
// "not null" rather than for a shape is what makes both
// kinds of failure visible.
failed = !json.isNull("err"),
)
}
}
}
}
@@ -0,0 +1,189 @@
package io.flamenet.client
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* Talking to a chain node.
*
* 🔴 **The failure this suite is really about is a refusal read as an answer.**
* A JSON-RPC error arrives with HTTP 200 — the transport succeeded, the node
* said no. A client that checks only the status code carries on with a body
* that has no `result` in it, fails somewhere less obvious, and throws away the
* node's own explanation on the way. "Insufficient funds for rent exemption" on
* screen versus a parse error nobody can act on is the whole difference.
*
* ⚠️ Against a `MockWebServer`, so these reach nothing real. See
* `TestSafetyTest` — a suite that signs up against a mock is the correct way to
* test signing up.
*/
class ChainRpcTest {
private lateinit var server: MockWebServer
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
@AfterTest fun stop() { server.shutdown() }
private fun rpc() = ChainRpc(server.url("/").toString())
private fun reply(json: String) =
server.enqueue(MockResponse().setBody(json).setHeader("Content-Type", "application/json"))
// ---- The refusal that looks like success ------------------------------
/**
* 🔴 The case the whole file exists for: HTTP 200, and a refusal in the
* body.
*/
@Test
fun `a JSON-RPC error arriving with HTTP 200 is a failure, carrying the node's words`() {
reply("""{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"insufficient funds"}}""")
val thrown = assertFailsWith<ChainRpc.Failure.Node> { rpc().balance("addr") }
assertEquals("insufficient funds", thrown.detail,
"the node's own explanation was replaced with a guess")
}
/** An error with no message still refuses, rather than reading as success. */
@Test
fun `an error with no message still refuses`() {
reply("""{"jsonrpc":"2.0","id":1,"error":{"code":-1}}""")
assertFailsWith<ChainRpc.Failure.Node> { rpc().balance("addr") }
}
/** A transport failure is its own thing, and keeps the status code. */
@Test
fun `an HTTP failure is reported as one`() {
server.enqueue(MockResponse().setResponseCode(503).setBody("busy"))
assertEquals(503, assertFailsWith<ChainRpc.Failure.Http> { rpc().balance("addr") }.code)
}
@Test
fun `an answer that is not JSON is malformed, not empty`() {
reply("<html>not a node</html>")
assertFailsWith<ChainRpc.Failure.Malformed> { rpc().balance("addr") }
}
/**
* ⚠️ A body with neither `result` nor `error` is malformed rather than a
* balance of zero. Reading a missing result as zero would show somebody an
* empty wallet that is not empty.
*/
@Test
fun `a body with no result is malformed rather than a zero balance`() {
reply("""{"jsonrpc":"2.0","id":1}""")
assertFailsWith<ChainRpc.Failure.Malformed> { rpc().balance("addr") }
}
// ---- Balance ---------------------------------------------------------
@Test
fun `a balance is read in lamports`() {
reply("""{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":123456789}}""")
assertEquals(123_456_789L, rpc().balance("addr"))
}
/** Zero is a balance, not an absence. */
@Test
fun `an empty account reads as zero rather than failing`() {
reply("""{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":0}}""")
assertEquals(0L, rpc().balance("addr"))
}
/**
* ⚠️ Lamports exceed what a 32-bit value holds long before the balance is
* interesting: 5 SOL is already over Int.MAX_VALUE. Read as a Long
* throughout, and this pins it.
*/
@Test
fun `a balance larger than an Int is read intact`() {
reply("""{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":5000000000}}""")
assertEquals(5_000_000_000L, rpc().balance("addr"))
}
@Test
fun `the request is a well-formed JSON-RPC call`() {
reply("""{"jsonrpc":"2.0","id":1,"result":{"value":0}}""")
rpc().balance("SomeAddress")
val sent = JSONObject(server.takeRequest().body.readUtf8())
assertEquals("2.0", sent.getString("jsonrpc"))
assertEquals("getBalance", sent.getString("method"))
assertEquals("SomeAddress", sent.getJSONArray("params").getString(0))
}
// ---- History ---------------------------------------------------------
/**
* 🔴 **A failed transaction still appears in an address's history.** Not
* showing that it failed would be the worst kind of wrong: somebody would
* believe a payment went through.
*/
@Test
fun `a failed transaction is reported as failed`() {
reply(
"""{"jsonrpc":"2.0","id":1,"result":[
{"signature":"sig-ok","slot":10,"blockTime":1700000000,"err":null},
{"signature":"sig-bad","slot":11,"blockTime":1700000100,
"err":{"InstructionError":[0,{"Custom":1}]}}]}"""
)
val history = rpc().history("addr")
assertEquals(2, history.size)
assertTrue(!history[0].failed, "a successful transaction was marked failed")
assertTrue(history[1].failed, "a FAILED transaction was shown as successful")
}
/**
* ⚠️ `err` is an object for one kind of failure and an array for another.
* Testing for "not null" rather than for a shape is what makes both
* visible — checking for an object alone would silently pass one class of
* failure off as a success.
*/
@Test
fun `an error reported as an array is still a failure`() {
reply("""{"jsonrpc":"2.0","id":1,"result":[
{"signature":"sig","slot":1,"blockTime":1,"err":["something"]}]}""")
assertTrue(rpc().history("addr").single().failed)
}
/**
* ⚠️ A very new transaction has no `blockTime` yet — the node has it and
* has not dated it. Null means "just now"; reading a missing value as 0
* would date it to 1970 on somebody's screen.
*/
@Test
fun `a transaction the node has not dated yet has no time, not 1970`() {
reply("""{"jsonrpc":"2.0","id":1,"result":[
{"signature":"sig","slot":1,"blockTime":null,"err":null}]}""")
assertNull(rpc().history("addr").single().blockTimeSeconds)
}
@Test
fun `a row with no signature is dropped rather than shown blank`() {
reply("""{"jsonrpc":"2.0","id":1,"result":[
{"slot":1,"err":null},
{"signature":"sig","slot":2,"err":null}]}""")
assertEquals(listOf("sig"), rpc().history("addr").map { it.signature })
}
@Test
fun `an address with no history reads as an empty list`() {
reply("""{"jsonrpc":"2.0","id":1,"result":[]}""")
assertTrue(rpc().history("addr").isEmpty())
}
@Test
fun `the history request carries the limit that was asked for`() {
reply("""{"jsonrpc":"2.0","id":1,"result":[]}""")
rpc().history("addr", limit = 7)
val sent = JSONObject(server.takeRequest().body.readUtf8())
assertEquals("getSignaturesForAddress", sent.getString("method"))
assertEquals(7, sent.getJSONArray("params").getJSONObject(1).getInt("limit"))
}
}