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. */ 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. */ @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. */ @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)`. 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. */ @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() 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. */ @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. 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) } }