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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user