Merge branch 'feat/wallet-keys-and-address' into 'main'

Derive wallet addresses and sign with them (Android)

See merge request paulhitt/flamenet-messenger-android!45
This commit was merged in pull request #72.
This commit is contained in:
Paul Hitt
2026-09-04 20:49:36 +00:00
3 changed files with 301 additions and 1 deletions
+16 -1
View File
@@ -339,7 +339,8 @@ the messenger rather than staying separate apps.
| Local vault: add, organise, delete | ⚪ |
| Bell–LaPadula labels and clearance | ⚪ |
| Share a file into a chat or group | ⚪ |
| Wallet engine: BIP-39, Base58, SLIP-0010 | 🟡 primitives built and pinned to the published vectors; no screen yet |
| Wallet engine: BIP-39, Base58, SLIP-0010 | ✅ pinned to the published vectors |
| 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 | ⚪ |
| Wallet: fresh receive address per receive | ⚪ |
| Wallet: balance and transactions from a node | ⚪ |
@@ -372,6 +373,20 @@ Two Kotlin-specific decisions, both money-relevant:
`BigInteger(1, bytes).toString(58)` remapped, and it silently drops leading
zero bytes — encoding two different keys to one address.
🔴 **Android can SIGN, and iOS's constraint does not apply here.** `AccountKdf`
notes that a raw Ed25519 seed needs `EdECPrivateKeySpec`, which Android exposes
only from API 33 — but the engine carries its own RFC 8032 (`Rfc8032`), so
`HdAccount.signingSeed` produces a key that signs on any supported device. The
wallet is not blocked on a platform floor.
⚠️ **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
gone, and neither the client nor the network will catch it. There is a passing
test that *finds* such a near-miss, so the limit is recorded rather than implied
— and it fails if the format ever gains a checksum, which is when the warnings
around typed addresses can be revisited.
---
## Settings
@@ -0,0 +1,95 @@
package io.flamenet.e2e
/**
* An account's receive keys, and the address each one prints as.
*
* 🔴 **[next] moves the index on.** A wallet that handed out the same address
* every time would let anyone watching a public chain group every payment
* somebody ever received — which is the one linkability the wallet can actually
* prevent, so it prevents it by default rather than on request. The chain
* cannot be persuaded to forget; this is the only lever there is.
*
* ⚠️ Every level is hardened, because SLIP-0010 defines no non-hardened
* derivation for ed25519. See [HdKey].
*/
data class HdAccount(
val seed: ByteArray,
/** SLIP-44 coin type. 501 is Solana's; ed25519 chains each have their own. */
val coinType: Long = SOLANA,
val account: Long = 0,
val nextIndex: Long = 0,
) {
override fun equals(other: Any?) = other is HdAccount &&
seed.contentEquals(other.seed) && coinType == other.coinType &&
account == other.account && nextIndex == other.nextIndex
override fun hashCode() =
31 * seed.contentHashCode() + (coinType * 31 + account * 7 + nextIndex).toInt()
/** BIP-44 shape, every level hardened as this curve requires. */
fun path(index: Long): String = "m/44H/${coinType}H/${account}H/0H/${index}H"
fun key(index: Long): HdKey = HdKey.master(seed, path(index))
/**
* The address at [index], and the path it came from.
*
* 🔴 **Base58 of the raw public key, with no checksum, because Solana
* addresses have none.** A mistyped address that still decodes to 32 bytes
* is a *valid* address belonging to nobody — money sent there is gone, and
* there is no layer below this that will catch it. Anything offering an
* address to be typed has to say so; this function cannot.
*/
fun receive(index: Long): Receive =
Receive(
index = index,
address = Base58.encode(Ed25519.fromSeed(key(index).privateKey).publicKey),
path = path(index),
)
data class Receive(val index: Long, val address: String, val path: String)
/**
* The signing key for [index].
*
* 🔴 **Same path, same curve, same code as [receive] — deliberately.** If
* these two ever disagreed the wallet would sign with a key that does not
* own the address it displayed. The transaction being rejected by the
* network is the GOOD outcome; the bad one is signing for an address
* somebody else controls.
*/
fun signingSeed(index: Long): ByteArray = key(index).privateKey
/** The next unused receive key, and the account with its counter advanced. */
fun next(): Pair<Receive, HdAccount> =
receive(nextIndex) to copy(nextIndex = nextIndex + 1)
companion object {
/**
* ⚠️ Solana's SLIP-44 coin type: the ed25519 chain family this
* derivation is defined for. Other ed25519 chains differ only in this
* number. secp256k1 chains (Bitcoin, Ethereum) need curve arithmetic
* this engine does not carry.
*/
const val SOLANA: Long = 501
/**
* Whether [address] could be an address on this chain.
*
* 🔴 **This says "well formed", NEVER "correct", and the difference is
* money.** A Solana address is Base58 of a 32-byte public key and
* carries no checksum — so a single mistyped character that happens to
* decode to 32 bytes produces a perfectly valid address that nobody
* holds the key for. Bitcoin's Base58Check would catch that; this
* cannot, and neither can the network.
*
* The only real defences are pasting rather than typing, and showing
* the address back in a form somebody can compare. A caller that treats
* a `true` here as "this address is right" has misread it.
*/
fun looksLikeAddress(address: String): Boolean {
val decoded = Base58.decode(address) ?: return false
return decoded.size == 32
}
}
}
@@ -0,0 +1,190 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* From a recovery phrase to an address somebody can be paid at.
*
* 🔴 **The property that matters most here is that the signing key and the
* displayed address come from the same derivation.** If they ever disagreed,
* the wallet would sign with a key that does not own the address it showed. The
* transaction being rejected is the GOOD outcome; the bad one is signing for an
* address somebody else controls.
*/
class HdAccountTest {
private val words by lazy { Bip39.englishWordlist() }
private val phrase by lazy { Bip39.phrase(ByteArray(16), words) }
private val seed by lazy { Bip39.seed(phrase) }
private fun account() = HdAccount(seed)
// ---- The path --------------------------------------------------------
/**
* ⚠️ BIP-44 with every level hardened, which is what this curve requires
* and what every other Solana wallet uses. A different shape here derives
* addresses nobody else can reproduce from the same phrase.
*/
@Test
fun `the derivation path is the standard Solana shape`() {
assertEquals("m/44H/501H/0H/0H/0H", account().path(0))
assertEquals("m/44H/501H/0H/0H/7H", account().path(7))
assertEquals("m/44H/501H/3H/0H/0H", HdAccount(seed, account = 3).path(0))
}
// ---- The property that guards the money ------------------------------
/**
* 🔴 The signing key owns the address that was displayed. Everything else
* in the wallet rests on this one line being true.
*/
@Test
fun `the signing key owns the address that was shown`() {
val account = account()
for (index in 0L..4L) {
val shown = account.receive(index)
val signing = account.signingSeed(index)
val fromSigningKey = Base58.encode(Ed25519.fromSeed(signing).publicKey)
assertEquals(shown.address, fromSigningKey,
"index $index would sign with a key that does not own its address")
}
}
/** And that key can actually sign — the engine carries RFC 8032 itself. */
@Test
fun `a derived key can sign and the signature verifies under its address`() {
val account = account()
val shown = account.receive(0)
val seedAt0 = account.signingSeed(0)
val message = "a transaction".toByteArray()
val signature = Ed25519.sign(seedAt0, message)
val publicKey = Base58.decode(shown.address)!!
// ⚠️ `(publicKey, signature, message)`. All three are ByteArray, so
// getting the order wrong compiles perfectly and fails at runtime —
// which is what happened writing this test, and is a good argument for
// the assertion existing at all.
assertTrue(Ed25519.verify(publicKey, signature, message),
"the signature does not verify under the address it was made for")
}
// ---- Fresh addresses -------------------------------------------------
/**
* 🔴 **A new address per receive is the point.** Reusing one lets anybody
* watching a public chain group every payment somebody ever received — the
* one linkability a wallet can prevent, since the chain will not forget.
*/
@Test
fun `every index gives a different address`() {
val account = account()
val addresses = (0L..9L).map { account.receive(it).address }
assertEquals(10, addresses.toSet().size, "two indices produced one address")
}
@Test
fun `next advances the counter and never repeats`() {
var account = account()
val seen = mutableListOf<String>()
repeat(5) {
val (receive, advanced) = account.next()
seen += receive.address
account = advanced
}
assertEquals(5, seen.toSet().size)
assertEquals(5L, account.nextIndex)
}
/** Different accounts under one phrase are different wallets. */
@Test
fun `account numbers separate`() {
assertNotEquals(
HdAccount(seed, account = 0).receive(0).address,
HdAccount(seed, account = 1).receive(0).address,
)
}
// ---- Restoring -------------------------------------------------------
/**
* ⚠️ The whole promise of a recovery phrase: the same words give back the
* same addresses, on any device and any implementation.
*/
@Test
fun `the same phrase restores the same addresses`() {
val restored = HdAccount(Bip39.seed(Bip39.phrase(ByteArray(16), words)))
assertEquals(account().receive(0).address, restored.receive(0).address)
assertEquals(account().receive(3).address, restored.receive(3).address)
}
/** A different phrase is a different wallet, not a near-miss. */
@Test
fun `a different phrase gives entirely different addresses`() {
val other = HdAccount(Bip39.seed(Bip39.phrase(ByteArray(16) { 1 }, words)))
assertNotEquals(account().receive(0).address, other.receive(0).address)
}
// ---- What an address is, and is not ----------------------------------
/** An address is Base58 of 32 bytes, so it decodes back to the key. */
@Test
fun `an address decodes back to a 32-byte key`() {
val shown = account().receive(0)
val decoded = Base58.decode(shown.address)
assertContentEquals(
Ed25519.fromSeed(account().signingSeed(0)).publicKey,
decoded,
)
assertEquals(32, decoded!!.size)
}
@Test
fun `a well-formed address is recognised`() {
assertTrue(HdAccount.looksLikeAddress(account().receive(0).address))
}
@Test
fun `something that is not an address is rejected`() {
assertFalse(HdAccount.looksLikeAddress(""))
assertFalse(HdAccount.looksLikeAddress("not an address"))
// Right alphabet, wrong length.
assertFalse(HdAccount.looksLikeAddress("abc"))
// Characters the alphabet excludes so a hand-copied address cannot
// become a different valid one.
assertFalse(HdAccount.looksLikeAddress("0OIl0OIl0OIl0OIl0OIl0OIl0OIl0OIl"))
}
/**
* 🔴 **The limit, asserted as a passing test so nobody reads
* `looksLikeAddress` as stronger than it is.**
*
* A Solana address carries NO checksum. A mistyped character that still
* decodes to 32 bytes is a perfectly valid address that nobody holds the
* key for — money sent there is gone, and neither this function nor the
* network will catch it. Bitcoin's Base58Check would; this cannot.
*
* The only real defences are pasting rather than typing, and showing the
* address back to be compared.
*/
@Test
fun `a corrupted address can still look valid, and that is not fixable here`() {
val real = account().receive(0).address
// Walk the alphabet at one position until the result still decodes to
// 32 bytes. On a checksummed format this search would fail.
val nearMiss = Base58.ALPHABET.asSequence()
.map { real.take(3) + it + real.drop(4) }
.firstOrNull { it != real && HdAccount.looksLikeAddress(it) }
assertTrue(
nearMiss != null,
"no single-character change produced a still-valid address — if this " +
"ever becomes true the format has gained a checksum, and the " +
"warnings written around typed addresses can be revisited",
)
assertNotEquals(real, nearMiss)
}
}