Derive wallet addresses and sign with them (Android)

`HdAccount` turns a recovery phrase into the BIP-44 path every other Solana
wallet uses, the Base58 address somebody can be paid at, and the key that signs
for it.

🔴 The property everything else rests on is that the signing key OWNS the
address that was displayed — same path, same curve, same code. If those ever
disagreed the wallet would sign with a key that does not own the address it
showed. The transaction being rejected by the network is the GOOD outcome; the
bad one is signing for an address somebody else controls. Tested across five
indices, and separately by signing a message and verifying it under the address.

🔴 Android can sign, and iOS's constraint does not apply. AccountKdf notes that
a raw Ed25519 seed needs EdECPrivateKeySpec, which Android exposes only from
API 33 — but the engine carries its own RFC 8032, so this works on any
supported device. The wallet is not blocked on a platform floor.

🔴 A fresh address per receive, by default rather than on request. Reusing one
lets anybody watching a public chain group every payment somebody ever received
— the single linkability a wallet can actually prevent, because the chain will
not forget.

⚠️ `looksLikeAddress` says WELL FORMED, never CORRECT, and the difference is
money. A Solana address carries no checksum, so one mistyped character that
still decodes to 32 bytes is a perfectly valid address nobody holds the key
for. Bitcoin's Base58Check would catch it; this cannot, and neither can the
network. There is a passing test that goes and 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 the point at which the warnings around typed addresses can be
revisited.

⚠️ A test bug found by the test failing rather than passing: `Ed25519.verify`
takes (publicKey, signature, message) and I had passed (publicKey, message,
signature). All three are ByteArray, so it compiled perfectly. Noted in the file
— it is a decent argument for asserting the signature verifies at all rather
than trusting that a key which derives must be a key that signs.

12 tests. App builds, every JVM suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 14:18:59 -04:00
co-authored by Claude Opus 5
parent 4ee0170dcc
commit 2e8c6776e2
3 changed files with 301 additions and 1 deletions
@@ -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)
}
}