From 7b8a440fc4aea1c45f6091e18c175ca828cbbb76 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 4 Sep 2026 14:43:31 -0400 Subject: [PATCH] Read a balance and a transaction history from a chain node (Android) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ChainRpc` asks a node what a public address holds and what has happened to it. The node is trusted with nothing: it never sees a key and 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". 🔴 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`, fails somewhere less obvious, and throws away the node's own explanation on the way — the difference between "insufficient funds for rent exemption" on screen and a parse error nobody can act on. Both cases are tested, including an error carrying no message. 🔴 A FAILED transaction still appears in an address's history, and is shown as failed. Not doing that is the worst kind of wrong: somebody would believe a payment went through. `err` is an OBJECT for one class of failure and an ARRAY for another, so the check is "not null" rather than a shape — testing for an object alone would pass one whole class of failure off as a success. Both shapes tested. ⚠️ A body with neither result nor error is malformed, never a balance of zero. Reading a missing result as zero would show somebody an empty wallet that is not empty. ⚠️ Lamports as a Long throughout, and to the edge of the UI. Five SOL already exceeds Int.MAX_VALUE, and converting to a fractional unit early means carrying rounding through code that compares and subtracts — a rounding error in a balance is money. ⚠️ A transaction the node has not dated yet reports no time rather than 1970. It has the transaction and has not assigned a block time; "just now" is the truth and the epoch is not. ⚠️ Which node somebody points at is a privacy decision the app has to state: asking a public RPC endpoint for an address's balance ties that address to this device's IP in 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. 15 tests, against a MockWebServer. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 --- PARITY.md | 16 +- .../kotlin/io/flamenet/client/ChainRpc.kt | 137 +++++++++++++ .../kotlin/io/flamenet/client/ChainRpcTest.kt | 189 ++++++++++++++++++ 3 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 client/src/main/kotlin/io/flamenet/client/ChainRpc.kt create mode 100644 client/src/test/kotlin/io/flamenet/client/ChainRpcTest.kt diff --git a/PARITY.md b/PARITY.md index 163b954..315f27f 100644 --- a/PARITY.md +++ b/PARITY.md @@ -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 diff --git a/client/src/main/kotlin/io/flamenet/client/ChainRpc.kt b/client/src/main/kotlin/io/flamenet/client/ChainRpc.kt new file mode 100644 index 0000000..1c95f11 --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/ChainRpc.kt @@ -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 { + 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"), + ) + } + } + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/ChainRpcTest.kt b/client/src/test/kotlin/io/flamenet/client/ChainRpcTest.kt new file mode 100644 index 0000000..d70cb07 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/ChainRpcTest.kt @@ -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 { 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 { 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 { rpc().balance("addr") }.code) + } + + @Test + fun `an answer that is not JSON is malformed, not empty`() { + reply("not a node") + assertFailsWith { 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 { 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")) + } +} -- 2.54.0