Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,30 @@ jobs:
run: bundle exec fastlane android flipcash_tests
env:
SKIP_COVERAGE: "true"

# TODO(cross-platform-vectors): wire instrumented vector tests once this CI has emulator support.
#
# Three androidTest suites require a device/emulator (JNI or Android-framework deps):
# - :libs:encryption:ed25519 connectedAndroidTest (JNI + android.util.Base64)
# - :libs:encryption:mnemonic connectedAndroidTest (wordlist in res/raw + JNI ed25519)
# - :libs:currency-math connectedAndroidTest (loads .bin tables from assets)
#
# To add: create a new job (e.g. `vector-instrumented-tests`) with:
# runs-on: ubuntu-latest # or macos-latest (faster KVM on Linux)
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-java@v3 (java-version: '21', distribution: 'corretto')
# - uses: reactivecircus/android-emulator-runner@v2
# with:
# api-level: 35 # matches compileSdk in build-logic convention plugins
# arch: x86_64
# script: >
# ./gradlew
# :libs:encryption:ed25519:connectedAndroidTest
# :libs:encryption:mnemonic:connectedAndroidTest
# :libs:currency-math:connectedAndroidTest
# --no-daemon
#
# The host-JVM vector suites (base58, solana_message, compact_message) are already covered by
# the flipcash-tests job above via `flipcashTestDebug` → :libs:encryption:base58 and
# :services:opencode are now in unitTestPaths (see settings.gradle.kts).
1 change: 1 addition & 0 deletions libs/encryption/base58/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ android {

dependencies {
testImplementation(kotlin("test"))
testImplementation(libs.kotlinx.serialization.json) // parse cross-platform test-vector fixtures
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.getcode.vendor

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
* GATE: this repo's Base58 must reproduce the canonical cross-platform fixtures exactly (encode and
* decode). The iOS repo asserts the identical fixtures against its Base58 — matching on both sides is
* what guarantees the apps agree on address/key encoding. Anchored to the Solana all-ones address.
*
* Pure-JVM host unit test (Base58 has no Android/JNI deps). Fixture synced from `code/test-vectors/`.
*/
class Base58VectorTest {

@Test
fun base58_matches_canonical_vectors() {
val text = javaClass.getResourceAsStream("/base58.json")!!.bufferedReader().use { it.readText() }
val vectors = Json.parseToJsonElement(text).jsonObject["vectors"]!!.jsonArray
assertTrue(vectors.isNotEmpty(), "no vectors loaded")

for (el in vectors) {
val o = el.jsonObject
val name = o["name"]!!.jsonPrimitive.content
val bytes = o["bytes"]!!.jsonPrimitive.content.hexToBytes()
val expected = o["base58"]!!.jsonPrimitive.content

assertEquals(expected, Base58.encode(bytes), "encode mismatch for $name")
assertTrue(Base58.decode(expected).contentEquals(bytes), "decode mismatch for $name")
}
}
}

private fun String.hexToBytes(): ByteArray =
if (isEmpty()) ByteArray(0)
else ByteArray(length / 2) { ((this[it * 2].digitToInt(16) shl 4) or this[it * 2 + 1].digitToInt(16)).toByte() }
42 changes: 42 additions & 0 deletions libs/encryption/base58/src/test/resources/base58.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"algorithm": "base58",
"alphabet": "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz",
"note": "Bitcoin/Solana Base58. Both apps must encode(bytes)==base58 and decode(base58)==bytes.",
"vectors": [
{
"name": "empty",
"bytes": "",
"base58": ""
},
{
"name": "single-zero",
"bytes": "00",
"base58": "1"
},
{
"name": "zeros-32",
"bytes": "0000000000000000000000000000000000000000000000000000000000000000",
"base58": "11111111111111111111111111111111"
},
{
"name": "hello-world",
"bytes": "48656c6c6f20576f726c64",
"base58": "JxF12TrwUP45BMd"
},
{
"name": "leading-zeros",
"bytes": "0000287fb4cd",
"base58": "11233QC4"
},
{
"name": "pubkey-rfc8032-1",
"bytes": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
"base58": "FVen3X669xLzsi6N2V91DoiyzHzg1uAgqiT8jZ9nS96Z"
},
{
"name": "pubkey-rfc8032-3",
"bytes": "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025",
"base58": "Hyx62wPQGyvXCoihZq1BrbUjBRh2LuNxWiiqMkfAuSZr"
}
]
}
4 changes: 4 additions & 0 deletions libs/encryption/ed25519/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,8 @@ android {
dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
implementation(libs.bundles.kotlinx.serialization)

// Cross-platform test-vector gate (instrumented: JNI + android.util.Base64 need a device).
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
}
49 changes: 49 additions & 0 deletions libs/encryption/ed25519/src/androidTest/assets/ed25519.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"algorithm": "ed25519",
"spec": "RFC 8032 (SHA-512). seed=32-byte private seed; publicKey/signature are standard outputs.",
"note": "Cross-platform parity gate: both apps must reproduce publicKey and signature for each seed/message.",
"vectors": [
{
"name": "rfc8032-test1",
"seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"message": "",
"publicKey": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
"signature": "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"
},
{
"name": "rfc8032-test2",
"seed": "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6f8",
"message": "72",
"publicKey": "f7cbf1b83a8ff48bd6f88cdf132b1fee4a1ffc436741f1e068d2d9c29a9308ae",
"signature": "4b5737a671f7b7f4d50848fc87277460cd65142d5752c17a2b7b10390a8803c48a52f04f37ca575fe456e632bff18de8bc32a38dec5535a874a850d18b4fe300"
},
{
"name": "rfc8032-test3",
"seed": "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7",
"message": "af82",
"publicKey": "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025",
"signature": "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"
},
{
"name": "zero-seed-empty",
"seed": "0000000000000000000000000000000000000000000000000000000000000000",
"message": "",
"publicKey": "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29",
"signature": "8f895b3cafe2c9506039d0e2a66382568004674fe8d237785092e40d6aaf483e4fc60168705f31f101596138ce21aa357c0d32a064f423dc3ee4aa3abf53f803"
},
{
"name": "zero-seed-32b",
"seed": "0000000000000000000000000000000000000000000000000000000000000000",
"message": "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
"publicKey": "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29",
"signature": "b715b42bcdf6a3755d83f500103441557410920c276efb3102752f36e301ccdec2e5015ebdd6595a1844518a69b85f156db8ed9792d85f483f5c779ae2b90b0f"
},
{
"name": "seed-ff-tx",
"seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"message": "0100000000000000dededededededededededededededededededededededededededededededededededededededededededededededededededededededededededededededede",
"publicKey": "76a1592044a6e4f511265bca73a604d90b0529d1df602be30a19a9257660d1f5",
"signature": "22cb95f107e77716d4084c004543abc3dfa7562fee71d3af63c844a357c75e0277a29d275f10a65c57c93c0b1d60a46cb1c49f045a2b82fbc03d113e1768c40a"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.getcode.ed25519

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

/**
* GATE: this repo's ed25519 (JNI) must reproduce the canonical cross-platform fixtures exactly.
* The iOS repo runs the identical fixtures against its ed25519 — matching outputs on both sides is
* what guarantees the apps agree. RFC 8032 anchors mean "matches fixture" == "correct".
*
* Instrumented (not a host unit test) because ed25519 is a JNI/NDK library and createKeyPair uses
* android.util.Base64 — both require the Android runtime. Fixtures live in androidTest/assets and are
* synced from the orchestrator's `code/test-vectors/` (single source of truth).
*/
@RunWith(AndroidJUnit4::class)
class Ed25519VectorTest {

@Test
fun ed25519_matches_canonical_vectors() {
val ctx = InstrumentationRegistry.getInstrumentation().context
val json = ctx.assets.open("ed25519.json").bufferedReader().use { it.readText() }
val vectors = JSONObject(json).getJSONArray("vectors")
assertTrue("no vectors loaded", vectors.length() > 0)

for (i in 0 until vectors.length()) {
val v = vectors.getJSONObject(i)
val name = v.getString("name")
val seed = v.getString("seed").hexToBytes()
val message = v.getString("message").hexToBytes()

val keyPair = Ed25519.createKeyPair(seed)
val publicKey = keyPair.publicKeyBytes
val signature = Ed25519.sign(message, keyPair)

assertEquals("public key mismatch for $name", v.getString("publicKey"), publicKey.toHex())
assertEquals("signature mismatch for $name", v.getString("signature"), signature.toHex())
assertTrue("verify failed for $name", Ed25519.verify(signature, message, publicKey))
}
}
}

private fun String.hexToBytes(): ByteArray =
ByteArray(length / 2) { ((this[it * 2].digitToInt(16) shl 4) or this[it * 2 + 1].digitToInt(16)).toByte() }

private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
4 changes: 4 additions & 0 deletions libs/encryption/mnemonic/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@ dependencies {
implementation(libs.androidx.core)

testImplementation(kotlin("test"))

// Cross-platform derivation test-vector gate (instrumented: wordlist res/raw + JNI ed25519).
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
}
66 changes: 66 additions & 0 deletions libs/encryption/mnemonic/src/androidTest/assets/slip10.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"algorithm": "bip39+slip10-ed25519",
"note": "BIP39 seed -> SLIP-0010 ed25519 (all indices force-hardened) -> ed25519 keypair. Apps must reproduce publicKey/address for each mnemonic+path.",
"vectors": [
{
"name": "abandon-x11-about m/44'/501'/0'/0'",
"mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"passphrase": "",
"path": "m/44'/501'/0'/0'",
"seedBip39": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
"derivedKey": "37df573b3ac4ad5b522e064e25b63ea16bcbe79d449e81a0268d1047948bb445",
"publicKey": "f036276246a75b9de3349ed42b15e232f6518fc20f5fcd4f1d64e81f9bd258f7",
"address": "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"
},
{
"name": "abandon-x11-about m/44'/501'/0'/0'/7665'/0",
"mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"passphrase": "",
"path": "m/44'/501'/0'/0'/7665'/0",
"seedBip39": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
"derivedKey": "1f317a98c39b1459a28fbc5d7e4ed9c9799dc69d194157ec7b9d645c4ed3a474",
"publicKey": "051d88bbb9c70cc1045090c3c01675620b060123f2d1f1700d8ef1f5dadcc5d8",
"address": "LyACZbC6QzPP3ixxyBjuCC1sKWuAi1bZU1xgTuFVpRD"
},
{
"name": "abandon-x11-about m/44'/501'/0'/0'/2335'/5",
"mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"passphrase": "",
"path": "m/44'/501'/0'/0'/2335'/5",
"seedBip39": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
"derivedKey": "4b83957e1b5ca6b16e9f39fdd38c88a6a96c38e61f9b4cecb7fda43e5e249764",
"publicKey": "7bc1de0ce3459814c74dae0529df060261d896dea23264e298eb90d1d236c123",
"address": "9L6c3GSoNLLXbXAoxCgHR1fkhbX96jNaLqmQnsRYUCo4"
},
{
"name": "legal-winner m/44'/501'/0'/0'",
"mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow",
"passphrase": "",
"path": "m/44'/501'/0'/0'",
"seedBip39": "878386efb78845b3355bd15ea4d39ef97d179cb712b77d5c12b6be415fffeffe5f377ba02bf3f8544ab800b955e51fbff09828f682052a20faa6addbbddfb096",
"derivedKey": "6987bdb06aa8a243a3019f41489ffa8e609c953a885a748d1849a8df760aa479",
"publicKey": "999d46fb3d1256f7049c8ed09314d7268612e8a91b800e91934463848305c98c",
"address": "BLeUXTx9thHGT7VJUtF9vHEmfMDgW1nnKZ9UVer2CoLX"
},
{
"name": "legal-winner m/44'/501'/0'/0'/7665'/0",
"mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow",
"passphrase": "",
"path": "m/44'/501'/0'/0'/7665'/0",
"seedBip39": "878386efb78845b3355bd15ea4d39ef97d179cb712b77d5c12b6be415fffeffe5f377ba02bf3f8544ab800b955e51fbff09828f682052a20faa6addbbddfb096",
"derivedKey": "82c2531aae3c058eb076618c696c3d496c3249b066dd528cb83adc4f4bbe8294",
"publicKey": "9ed602ebfe2399e961e3a08535a50fd4391fdefc0341b1c790e0c4947ccd16a0",
"address": "Bh2gxt6UiDWjWkmsfK9qp46kRrjTPgvXn7DhvaPTxUr3"
},
{
"name": "legal-winner m/44'/501'/0'/0'/2335'/5",
"mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow",
"passphrase": "",
"path": "m/44'/501'/0'/0'/2335'/5",
"seedBip39": "878386efb78845b3355bd15ea4d39ef97d179cb712b77d5c12b6be415fffeffe5f377ba02bf3f8544ab800b955e51fbff09828f682052a20faa6addbbddfb096",
"derivedKey": "4eb3bb8632aa83691f51a4cff44021ff785df069432007bb023e858b6416f6ec",
"publicKey": "b5310f3190a4a39d5146f17072a767fb5c566593e4f2417d293d11fcd3b246f7",
"address": "DCJBY1iQroEzsNi2BMwu3zNmnB27rCV6iJHHJDAqTTmg"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.getcode.crypt

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

/**
* GATE: this repo's BIP39 + SLIP-0010 (ed25519) key derivation must reproduce the canonical
* cross-platform fixtures exactly. The iOS repo asserts the identical fixtures — matching on both
* sides guarantees the apps derive the SAME account from a mnemonic (divergence would mean a user's
* key differs by platform). Anchored to the official BIP39 + SLIP-0010 test vectors.
*
* Instrumented: derivation loads the BIP-39 wordlist from res/raw (MnemonicCache) and finishes with
* the JNI ed25519 keypair — both need the Android runtime. Fixture synced from `code/test-vectors/`.
*/
@RunWith(AndroidJUnit4::class)
class Slip10DerivationVectorTest {

@Test
fun derivation_matches_canonical_vectors() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
MnemonicCache.init(instrumentation.targetContext) // load BIP-39 wordlist (res/raw/english.txt)

val json = instrumentation.context.assets.open("slip10.json").bufferedReader().use { it.readText() }
val vectors = JSONObject(json).getJSONArray("vectors")
assertTrue("no vectors loaded", vectors.length() > 0)

for (i in 0 until vectors.length()) {
val v = vectors.getJSONObject(i)
val name = v.getString("name")
val words = v.getString("mnemonic").split(" ")
val path = DerivePath.newInstance(v.getString("path"))!!

val keyPair = MnemonicPhrase.newInstance(words)!!.getSolanaKeyPair(path)

assertEquals("public key mismatch for $name", v.getString("publicKey"), keyPair.publicKeyBytes.toHex())
}
}
}

private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
Loading
Loading