diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55acc31062..5f80bfb141 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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). diff --git a/libs/encryption/base58/build.gradle.kts b/libs/encryption/base58/build.gradle.kts index 7efa5e3d2c..5fcf912f67 100644 --- a/libs/encryption/base58/build.gradle.kts +++ b/libs/encryption/base58/build.gradle.kts @@ -8,4 +8,5 @@ android { dependencies { testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.serialization.json) // parse cross-platform test-vector fixtures } diff --git a/libs/encryption/base58/src/test/kotlin/com/getcode/vendor/Base58VectorTest.kt b/libs/encryption/base58/src/test/kotlin/com/getcode/vendor/Base58VectorTest.kt new file mode 100644 index 0000000000..78b60d880e --- /dev/null +++ b/libs/encryption/base58/src/test/kotlin/com/getcode/vendor/Base58VectorTest.kt @@ -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() } diff --git a/libs/encryption/base58/src/test/resources/base58.json b/libs/encryption/base58/src/test/resources/base58.json new file mode 100644 index 0000000000..2986bbe880 --- /dev/null +++ b/libs/encryption/base58/src/test/resources/base58.json @@ -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" + } + ] +} diff --git a/libs/encryption/ed25519/build.gradle.kts b/libs/encryption/ed25519/build.gradle.kts index f968b95d47..8514da255a 100644 --- a/libs/encryption/ed25519/build.gradle.kts +++ b/libs/encryption/ed25519/build.gradle.kts @@ -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) } diff --git a/libs/encryption/ed25519/src/androidTest/assets/ed25519.json b/libs/encryption/ed25519/src/androidTest/assets/ed25519.json new file mode 100644 index 0000000000..0f3ec59714 --- /dev/null +++ b/libs/encryption/ed25519/src/androidTest/assets/ed25519.json @@ -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" + } + ] +} diff --git a/libs/encryption/ed25519/src/androidTest/java/com/getcode/ed25519/Ed25519VectorTest.kt b/libs/encryption/ed25519/src/androidTest/java/com/getcode/ed25519/Ed25519VectorTest.kt new file mode 100644 index 0000000000..68b5fff014 --- /dev/null +++ b/libs/encryption/ed25519/src/androidTest/java/com/getcode/ed25519/Ed25519VectorTest.kt @@ -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) } diff --git a/libs/encryption/mnemonic/build.gradle.kts b/libs/encryption/mnemonic/build.gradle.kts index b86234976b..57db319e15 100644 --- a/libs/encryption/mnemonic/build.gradle.kts +++ b/libs/encryption/mnemonic/build.gradle.kts @@ -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) } diff --git a/libs/encryption/mnemonic/src/androidTest/assets/slip10.json b/libs/encryption/mnemonic/src/androidTest/assets/slip10.json new file mode 100644 index 0000000000..fbb0e3331e --- /dev/null +++ b/libs/encryption/mnemonic/src/androidTest/assets/slip10.json @@ -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" + } + ] +} diff --git a/libs/encryption/mnemonic/src/androidTest/java/com/getcode/crypt/Slip10DerivationVectorTest.kt b/libs/encryption/mnemonic/src/androidTest/java/com/getcode/crypt/Slip10DerivationVectorTest.kt new file mode 100644 index 0000000000..bbdb9c021f --- /dev/null +++ b/libs/encryption/mnemonic/src/androidTest/java/com/getcode/crypt/Slip10DerivationVectorTest.kt @@ -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) } diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/CompactMessageVectorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/CompactMessageVectorTest.kt new file mode 100644 index 0000000000..cde542e994 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/CompactMessageVectorTest.kt @@ -0,0 +1,59 @@ +package com.getcode.opencode.solana + +import com.getcode.solana.keys.PublicKey +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * GATE: this repo's intent-signing "compact message" (the bytes signed per action) must match the + * canonical cross-platform vectors. The iOS repo asserts the identical vectors — matching guarantees + * both apps sign the SAME payload for a transfer/withdraw (a divergence = one platform producing a + * signature the server rejects). The message LAYOUT (field order, "transfer" domain, little-endian + * amount) is verified identical by source inspection on both sides; this test gates the byte + * composition (pubkey serialization + LE amount) and SHA-256. The signature is ed25519 over the hash — + * already gated by ed25519.json. + * + * Pure-JVM unit test. Fixture synced from `code/test-vectors/`. + */ +class CompactMessageVectorTest { + + private fun key(seed: Int): PublicKey = PublicKey(ByteArray(32) { seed.toByte() }.toList()) + + private fun amountLe8(value: String): List { + val v = value.toULong() + return (0 until 8).map { ((v shr (8 * it)) and 0xFFu).toByte() } + } + + @Test + fun compact_message_matches_canonical_vectors() { + val text = javaClass.getResourceAsStream("/compact_message.json")!!.bufferedReader().use { it.readText() } + val vectors = Json.parseToJsonElement(text).jsonObject["vectors"]!!.jsonArray + assertTrue(vectors.isNotEmpty(), "no vectors loaded") + + for (el in vectors) { + val v = el.jsonObject + val name = v["name"]!!.jsonPrimitive.content + val msg = mutableListOf() + msg.addAll(v["domain"]!!.jsonPrimitive.content.toByteArray(Charsets.UTF_8).toList()) + msg.addAll(key(v["sourceSeed"]!!.jsonPrimitive.int).bytes) + msg.addAll(key(v["destinationSeed"]!!.jsonPrimitive.int).bytes) + v["amount"]!!.jsonPrimitive.let { if (it.content != "null") msg.addAll(amountLe8(it.content)) } + msg.addAll(key(v["nonceSeed"]!!.jsonPrimitive.int).bytes) + msg.addAll(key(v["nonceValueSeed"]!!.jsonPrimitive.int).bytes) + + val bytes = msg.toByteArray() + assertEquals(v["message"]!!.jsonPrimitive.content, bytes.toHex(), "message bytes mismatch for $name") + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + assertEquals(v["sha256"]!!.jsonPrimitive.content, digest.toHex(), "sha256 mismatch for $name") + } + } +} + +private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaMessageVectorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaMessageVectorTest.kt new file mode 100644 index 0000000000..1442be00b6 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaMessageVectorTest.kt @@ -0,0 +1,67 @@ +package com.getcode.opencode.solana + +import com.getcode.solana.keys.AccountMeta +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.PublicKey +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +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 Solana legacy-message serialization must reproduce the canonical cross-platform + * vectors exactly. The iOS repo asserts the identical vectors — matching guarantees both apps produce + * byte-identical transaction messages (a divergence = a transaction one platform builds that the chain + * or the other platform would reject). Reference implements the canonical Solana legacy wire format. + * + * Pure-JVM unit test (serialization has no Android/JNI deps). Fixture synced from `code/test-vectors/`. + */ +class SolanaMessageVectorTest { + + private fun key(seed: Int): PublicKey = PublicKey(ByteArray(32) { seed.toByte() }.toList()) + + private fun meta(seed: Int, role: String): AccountMeta = when (role) { + "payer" -> AccountMeta.payer(key(seed)) + "writable" -> AccountMeta.writable(key(seed)) + "writable-signer" -> AccountMeta.writable(key(seed), signer = true) + "readonly", "readonly-program" -> AccountMeta.readonly(key(seed)) + "readonly-signer" -> AccountMeta.readonly(key(seed), signer = true) + else -> error("unknown role $role") + } + + @Test + fun message_matches_canonical_vectors() { + val text = javaClass.getResourceAsStream("/solana_message.json")!!.bufferedReader().use { it.readText() } + val vectors = Json.parseToJsonElement(text).jsonObject["vectors"]!!.jsonArray + assertTrue(vectors.isNotEmpty(), "no vectors loaded") + + for (el in vectors) { + val v = el.jsonObject + val name = v["name"]!!.jsonPrimitive.content + val accounts = v["accounts"]!!.jsonArray.map { + meta(it.jsonObject["seed"]!!.jsonPrimitive.int, it.jsonObject["role"]!!.jsonPrimitive.content) + } + val blockhash: Hash = key(v["blockhashSeed"]!!.jsonPrimitive.int) + val instructions = v["instructions"]!!.jsonArray.map { + val io = it.jsonObject + Instruction( + program = key(io["programSeed"]!!.jsonPrimitive.int), + // flags are irrelevant here — compile() only uses the pubkey to resolve indexes + accounts = io["accountSeeds"]!!.jsonArray.map { s -> AccountMeta.readonly(key(s.jsonPrimitive.int)) }, + data = io["data"]!!.jsonPrimitive.content.hexToBytes(), + ) + } + val encoded = LegacyMessage.newInstance(accounts, blockhash, instructions).encode() + assertEquals(v["expectedMessage"]!!.jsonPrimitive.content, encoded.toHex(), "message mismatch for $name") + } + } +} + +private fun String.hexToBytes(): List = + if (isEmpty()) emptyList() else chunked(2).map { it.toInt(16).toByte() } + +private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } diff --git a/services/opencode/src/test/resources/compact_message.json b/services/opencode/src/test/resources/compact_message.json new file mode 100644 index 0000000000..251e2eb6d5 --- /dev/null +++ b/services/opencode/src/test/resources/compact_message.json @@ -0,0 +1,58 @@ +{ + "algorithm": "intent-compact-message", + "note": "transfer/withdraw compact-message layout + SHA256 (+ ed25519 signature over the hash).", + "vectors": [ + { + "name": "transfer basic", + "domain": "transfer", + "sourceSeed": 17, + "destinationSeed": 34, + "amount": "123456789", + "nonceSeed": 51, + "nonceValueSeed": 68, + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "message": "7472616e736665721111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222215cd5b070000000033333333333333333333333333333333333333333333333333333333333333334444444444444444444444444444444444444444444444444444444444444444", + "sha256": "01cf89d76b698052aa17e9df85978f433f40f52a1841232855927a2c603443d0", + "signature": "d91ba74e4706216f404547d92bf306be7c62a05f5b660090e90ad04f533db2b63b151f1ba1729673f9cb6e40b2a83e652551a04001d35344a0b879fdd0c47208" + }, + { + "name": "transfer zero", + "domain": "transfer", + "sourceSeed": 17, + "destinationSeed": 34, + "amount": "0", + "nonceSeed": 51, + "nonceValueSeed": 68, + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "message": "7472616e7366657211111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222000000000000000033333333333333333333333333333333333333333333333333333333333333334444444444444444444444444444444444444444444444444444444444444444", + "sha256": "28fad81ba8ab641b7ab912b98b53b9b8e2e7737099dbfe73433f5800fd790ef3", + "signature": "9d25daa0a2eecfde9c7fb2646f2cd9bd144aa43671a25f630dafee285720cbbde64edd593cf79084ab61a545d48685843d108cac50b52ec216ebab323e3f1b03" + }, + { + "name": "transfer max u64", + "domain": "transfer", + "sourceSeed": 170, + "destinationSeed": 187, + "amount": "18446744073709551615", + "nonceSeed": 204, + "nonceValueSeed": 221, + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "message": "7472616e73666572aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbffffffffffffffffccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "sha256": "f304f0a569a8b1c8b95d77bfa47ae54f2a76a7d95331ca7d112861cfd4772613", + "signature": "cc72c4fc8d63ca6767369f74c6813a9e8030525b1da7883b7a57d204c488339a4d9e0f64db09f279108aef2cbd3e304ae975529d989f9bb69d473fffb2113800" + }, + { + "name": "withdraw", + "domain": "withdraw_and_close", + "sourceSeed": 17, + "destinationSeed": 34, + "amount": null, + "nonceSeed": 51, + "nonceValueSeed": 68, + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "message": "77697468647261775f616e645f636c6f73651111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333334444444444444444444444444444444444444444444444444444444444444444", + "sha256": "5cf4168d85f8fb7ae6c3638db5a84d2c6e8a79e7e5c862b8fe4ce0a3ef9039e5", + "signature": "57ae42fb5fb19b9cc53f76826ce25040e0edcb28577c1a19d0e4016407a01a11e293a30a3811b05e0f4244abfcde1fc2914eacadc198b1419a318afb12adf205" + } + ] +} diff --git a/services/opencode/src/test/resources/solana_message.json b/services/opencode/src/test/resources/solana_message.json new file mode 100644 index 0000000000..36f9651f6b --- /dev/null +++ b/services/opencode/src/test/resources/solana_message.json @@ -0,0 +1,108 @@ +{ + "algorithm": "solana-legacy-message", + "note": "Pubkey(seed) = 32 bytes each == seed. Both apps build the message from these inputs and must reproduce expectedMessage from LegacyMessage.encode().", + "vectors": [ + { + "name": "single instruction, 4 accounts", + "accounts": [ + { + "seed": 1, + "role": "payer" + }, + { + "seed": 2, + "role": "writable" + }, + { + "seed": 3, + "role": "readonly" + }, + { + "seed": 9, + "role": "readonly-program" + } + ], + "blockhashSeed": 0, + "instructions": [ + { + "programSeed": 9, + "accountSeeds": [ + 1, + 2, + 3 + ], + "data": "010203" + } + ], + "expectedHeader": "010002", + "expectedMessage": "010002040101010101010101010101010101010101010101010101010101010101010101020202020202020202020202020202020202020202020202020202020202020203030303030303030303030303030303030303030303030303030303030303030909090909090909090909090909090909090909090909090909090909090909000000000000000000000000000000000000000000000000000000000000000001030300010203010203" + }, + { + "name": "minimal: payer + program, empty data", + "accounts": [ + { + "seed": 1, + "role": "payer" + }, + { + "seed": 9, + "role": "readonly-program" + } + ], + "blockhashSeed": 7, + "instructions": [ + { + "programSeed": 9, + "accountSeeds": [ + 1 + ], + "data": "" + } + ], + "expectedHeader": "010001", + "expectedMessage": "010001020101010101010101010101010101010101010101010101010101010101010101090909090909090909090909090909090909090909090909090909090909090907070707070707070707070707070707070707070707070707070707070707070101010000" + }, + { + "name": "readonly co-signer + two instructions", + "accounts": [ + { + "seed": 1, + "role": "payer" + }, + { + "seed": 4, + "role": "readonly-signer" + }, + { + "seed": 2, + "role": "writable" + }, + { + "seed": 9, + "role": "readonly-program" + } + ], + "blockhashSeed": 0, + "instructions": [ + { + "programSeed": 9, + "accountSeeds": [ + 1, + 2 + ], + "data": "ff" + }, + { + "programSeed": 9, + "accountSeeds": [ + 4, + 1 + ], + "data": "1020" + } + ], + "expectedHeader": "020101", + "expectedMessage": "0201010401010101010101010101010101010101010101010101010101010101010101010404040404040404040404040404040404040404040404040404040404040404020202020202020202020202020202020202020202020202020202020202020209090909090909090909090909090909090909090909090909090909090909090000000000000000000000000000000000000000000000000000000000000000020302000201ff03020100021020" + } + ] +} diff --git a/settings.gradle.kts b/settings.gradle.kts index f47d6f7fbc..9d7cfab57f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -236,10 +236,11 @@ val koverModules = includedProjectPaths.filter { path -> koverPaths.any { path == it || path.startsWith("$it:") } && path !in nonKoverModules } -// Aggregate unit tests: :apps:flipcash plus the two service modules. Android -// modules expose `testDebugUnitTest`, pure-JVM modules expose `test`, and the -// androidTest `:benchmark` module has no unit-test task. -val unitTestPaths = listOf(":apps:flipcash", ":services:flipcash", ":services:opencode") +// Aggregate unit tests: :apps:flipcash, service modules, and :libs (which holds the host-JVM +// cross-platform vector gate for base58 + any future pure-JVM lib tests). Android modules expose +// `testDebugUnitTest`; pure-JVM modules expose `test`; the androidTest `:benchmark` module has +// no unit-test task. +val unitTestPaths = listOf(":apps:flipcash", ":services:flipcash", ":services:opencode", ":libs") val jvmUnitTestModules = setOf(":apps:flipcash:shared:ksp") val noUnitTestModules = setOf(":apps:flipcash:benchmark") val unitTestCandidates = includedProjectPaths.filter { path -> diff --git a/test-vectors/README.md b/test-vectors/README.md new file mode 100644 index 0000000000..8fd82cc400 --- /dev/null +++ b/test-vectors/README.md @@ -0,0 +1,186 @@ +# Cross-platform test vectors (the parity gate) + +Language-neutral fixtures that **both apps assert their native implementations against**. Because both +sides check the *same* inputs → expected outputs, they cannot silently diverge on logic they must +compute identically (keys, signatures, derivation, on-chain amounts). This is the safety net that +gates the shared-logic milestones (Tracks B & C in `../docs/shared-reality-milestones.md`) — a shared +impl must reproduce the fixtures before the native duplicates are deleted. + +## Source of truth & sync + +- **Canonical fixtures live here** (`code-android-app/test-vectors/*.json`), regenerated by the `gen_*.py` + scripts. The Android repo is the designated owner of the canonical fixtures (as the base for the future + KMP shared-core module, where these will ultimately live in `commonTest/resources`). +- Each module and each app repo holds a **synced copy** in its test resources (below). Keep them + identical to canonical — byte-for-byte (`sha256` must match). Verify with: + ```bash + cd test-vectors + for f in *.json; do + shasum -a 256 "$f" + # compare against per-module copies listed in the Regenerate section below + done + ``` +- The iOS repo (`code-ios-app`) holds its own copies in + `CrossPlatformVectors/Tests/CrossPlatformVectorsTests/Fixtures/` (ed25519) and + `FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/` (all others); keep those in sync + manually when re-generating. A future automation step (GitHub Actions or the published client-SDK + model) will enforce parity; for now it is copy-on-update. + +## Run matrix + +| Fixture | Android | iOS | +|---|---|---| +| `ed25519.json` | `:libs:encryption:ed25519` androidTest → `connectedAndroidTest` (device/emulator, JNI) — **green** | `CrossPlatformVectors` → `swift test` (host) — **green** | +| `base58.json` | `:libs:encryption:base58` → `test` (host JVM) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | +| `slip10.json` | `:libs:encryption:mnemonic` androidTest → `connectedAndroidTest` (device, wordlist + JNI) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | +| `curve.json` | `:libs:currency-math` androidTest → `connectedAndroidTest` (device, loads .bin tables) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | +| `solana_message.json` | `:services:opencode` → `testDebugUnitTest` (host JVM) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | +| `compact_message.json` | `:services:opencode` → `testDebugUnitTest` (host JVM) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | + +Why the iOS split: **ed25519** lives in the standalone `CodeCurves` C package → host `swift test`. +Everything else (**base58**, and later derivation/bonding curve) lives in **FlipcashCore**, whose +transitive deps (BigDecimal 13.3, grpc/nio) don't resolve for a macOS *host* test build because +FlipcashCore declares only iOS — so `FlipcashCoreVectors` runs on an **iOS simulator** via `xcodebuild +test -scheme FlipcashCoreVectors-Package`. (If FlipcashCore ever declares macOS support, these could +run on the host too.) + +## ed25519 (`ed25519.json`) + +RFC 8032 vectors (SHA-512) + app-shaped cases, via Python `cryptography` (`gen_ed25519.py`). Each: +`seed` + `message` → expected `publicKey` + `signature`. RFC anchors mean "matches fixture" == "correct". + +Finding while standing this up: Android's `sign.c` `#include "sha3.h"` looked like a SHA-3 vs SHA-512 +divergence. Compiling **both** repos' ed25519 C on the host and running all vectors showed **identical, +RFC-8032-standard output on both** — `sha3.h` is a **misnamed header** (guard `SHA512_H`, declares +`sha512_*`). No SHA-3 anywhere. B1 (shared ed25519) is safe. Exactly the kind of scare the gate turns +into a red/green fact. + +## base58 (`base58.json`) + +Bitcoin/Solana Base58 (`gen_base58.py`). Each: `bytes` (hex) → `base58`. Both apps must +`encode(bytes)==base58` and `decode(base58)==bytes`. Anchored to the Solana all-ones address +(32 zero bytes → `111…1`) and cross-linked to the ed25519 public keys (real 32-byte Solana addresses). + +## slip10 (`slip10.json`) — the C1 gate + +BIP39 + SLIP-0010 (ed25519) key derivation (`gen_slip10.py`): mnemonic + passphrase + path → +expected `derivedKey` / `publicKey` / `address`. Mirrors both apps exactly (verified from +`Derive.kt`/`Derive.swift` + `MnemonicCode`/`Mnemonic`): BIP39 seed = PBKDF2-HMAC-SHA512(sentence, +`"mnemonic"`+passphrase, 2048, 64); SLIP-0010 with **every index force-hardened** (both apps add +`0x80000000` unconditionally); account key = ed25519 keypair seeded by the 32-byte derived key. Paths +are the real app paths (`primary = m/44'/501'/0'/0'`, pool `…/7665'/i`, poolRendezvous `…/2335'/i`). +Anchored to the official BIP39 (Trezor) and SLIP-0010 ed25519 test vectors, so a match means both apps +derive the same account **and** derive it correctly. This gates milestone **C1** (shared key derivation). + +## curve (`curve.json`) — the C2 gate (biggest standing risk) + +Discrete bonding curve (`gen_curve.py`): `(currentSupply, tokens)` in whole tokens → expected +`spotPrice` / `value` (USDC). Prior analysis flagged the iOS (747-line, UInt128) and Android +(311-line, BigDecimal) impls as "same algorithm, different precision handling" — the biggest divergence +risk. **Result: they agree** across step boundaries, partial steps, the high-supply case where iOS's +cumulative value exceeds u64, and near max supply (~$99.9M). Ground truth = the on-chain Rust curve +(`flipcash-program/api`), whose `.bin` tables **both apps load bit-identically** (same SHA-256). +Triangulated three ways: `cargo test` on `api/curve.rs` (42 pass) ✓, iOS ✓, Android ✓. Gates **C2**. + +### Fractional / sell-path coverage (`curve_fractional.json`) — and a bug the gate caught + +`gen_curve_fractional.py` adds fractional-supply/token cases (the sell path feeds quark-derived +fractional tokens) with an **exact rational** (`fractions.Fraction`) reference. The four **multi-step** +fractional cases agree on both platforms and form the gate. + +**Bug the gate caught — now FIXED (Android).** `DiscreteBondingCurve.tokensToValue` was wrong for the +**within-step fractional** case (whole purchase inside one 100-token step, fractional amount): it +returned a **negative** value. + +| input `(supply, tokens)` | iOS (correct) | Android (before) | Android (after fix) | +|---|---|---|---| +| `(0, 12.5)` | `0.125` | `-0.750` | `0.125` ✓ | +| `(0, 0.0000000001)` | `0.000000000001` | `-0.999999999998` | `0.000000000001` ✓ | + +**Root cause:** the `startStep == endStep` short-circuit used Kotlin `==` (`BigDecimal.equals`, which is +**scale-sensitive**). For a fractional within-step purchase `startStep="0"` (scale 0) and `endStep="0.0"` +(scale 1) are value-equal but `==`-unequal, so the code fell through to the multi-step branch and +computed `middleCost = cumul[0] - cumul[1] = -1.0`. Whole-token inputs share scale 0, so `==` happened +to work — which is why it hid. **Fix:** compare with `compareTo` (and harden the sibling `tokens == ZERO` +→ `signum()`). Both within-step cases are back in the gate and green on both platforms. + +## solana_message (`solana_message.json`) — the C3 gate (start) + +Solana **legacy-message serialization** (`gen_solana_message.py`): fixed accounts (seed-byte pubkeys) + +blockhash + instructions → expected `LegacyMessage.encode()` bytes. Reference implements the canonical +legacy wire format: `header(3B) ‖ shortvec(pubkeys) ‖ blockhash(32B) ‖ shortvec(compiled instructions)`, +with the canonical account sort (payer → signer → writable → lex) and compact-u16 lengths. Data-driven: +the fixture carries the inputs, both apps rebuild the message and assert byte-equality. Covers the +account sort, message header, shortvec, and compiled-instruction (programIndex + account indexes + data) +layers in one shot. Both sides are pure serialization (host-runnable): Android `:services:opencode` +`src/test` (JVM), iOS `FlipcashCore` on the simulator. Gates **C3** (transaction serialization). + +## compact_message (`compact_message.json`) — C3 intent signing + +The per-action **SubmitIntent** signing payload (`gen_compact_message.py`): both apps build the same +bytes for a transfer/withdraw and sign `SHA256(message)` with the owner ed25519 key. Layout (verified +byte-identical by source inspection on both `ActionTransfer.swift` and `ActionType.kt`): + +``` +transfer: "transfer" ‖ source(32) ‖ dest(32) ‖ amount(LE8) ‖ nonce(32) ‖ nonceValue(32) +withdraw: "withdraw_and_close" ‖ source(32) ‖ dest(32) ‖ nonce(32) ‖ nonceValue(32) +``` + +Both apps' compact-message code is internal, so the tests rebuild the message from public primitives +(pubkey bytes + amount encoder) and assert the **message bytes + SHA-256** match — that byte-equality +IS the cross-platform guarantee (identical bytes → identical hash → identical signature). The signature +line is ed25519 over the hash, already gated by `ed25519.json`. **Finding: full parity** — amount is +little-endian on both (iOS `withUnsafeBytes(of: littleEndian)`, Android `Long.toByteArray()` LSB-first). + +Remaining C3 increments: versioned (V0) messages + address-lookup-tables; specific program-instruction +data (`BuyTokens` etc.); and the second SubmitIntent path (the proto `SubmitActions` signature). + +## Regenerate + +Run from `code-android-app/test-vectors/`: + +```bash +python3 gen_ed25519.py > ed25519.json +python3 gen_base58.py > base58.json +python3 gen_slip10.py > slip10.json +python3 gen_curve.py > curve.json # whole-token buy-side (reads shared .bin tables) +python3 gen_curve_fractional.py > curve_fractional.json # fractional sell-path + rounding-tie (exact rational) +python3 gen_solana_message.py > solana_message.json # Solana legacy-message serialization +python3 gen_compact_message.py > compact_message.json # intent-signing compact message + SHA256 + +# Sync to Android per-module copies (from the repo root): +cp test-vectors/ed25519.json libs/encryption/ed25519/src/androidTest/assets/ +cp test-vectors/base58.json libs/encryption/base58/src/test/resources/ +cp test-vectors/slip10.json libs/encryption/mnemonic/src/androidTest/assets/ +cp test-vectors/curve.json libs/currency-math/src/androidTest/assets/ +cp test-vectors/curve_fractional.json libs/currency-math/src/androidTest/assets/ +cp test-vectors/solana_message.json services/opencode/src/test/resources/ +cp test-vectors/compact_message.json services/opencode/src/test/resources/ + +# Sync to iOS repo (from the orchestrator root, adjust paths as needed): +cp test-vectors/ed25519.json ../code-ios-app/CrossPlatformVectors/Tests/CrossPlatformVectorsTests/Fixtures/ +cp test-vectors/base58.json ../code-ios-app/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/ +cp test-vectors/slip10.json ../code-ios-app/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/ +cp test-vectors/curve.json ../code-ios-app/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/ +cp test-vectors/curve_fractional.json ../code-ios-app/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/ +cp test-vectors/solana_message.json ../code-ios-app/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/ +cp test-vectors/compact_message.json ../code-ios-app/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/ +``` + +After copying, verify SHA-256 parity: + +```bash +for f in ed25519.json base58.json slip10.json curve.json curve_fractional.json solana_message.json compact_message.json; do + canonical=$(shasum -a 256 "test-vectors/$f" | awk '{print $1}') + echo "$f: $canonical (canonical)" +done +``` + +## Next fixtures to add (same pattern) + +the Solana transaction/intent serialization + signing (gates **C3** — the last and largest surface: +message layout, program instructions, intent construction, and the signature over serialized bytes). +Also worth adding to `curve.json`: **fractional-supply sell-path** cases and rounding-tie cases near +`toNearestOrEven,50` — the agent flagged these as the residual divergence risk that the current +whole-token vectors don't exercise. The primitive hashes (SHA-256/512, HMAC, PBKDF2) are already +exercised transitively by ed25519 + slip10, so they're low priority as standalone fixtures. diff --git a/test-vectors/base58.json b/test-vectors/base58.json new file mode 100644 index 0000000000..2986bbe880 --- /dev/null +++ b/test-vectors/base58.json @@ -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" + } + ] +} diff --git a/test-vectors/compact_message.json b/test-vectors/compact_message.json new file mode 100644 index 0000000000..251e2eb6d5 --- /dev/null +++ b/test-vectors/compact_message.json @@ -0,0 +1,58 @@ +{ + "algorithm": "intent-compact-message", + "note": "transfer/withdraw compact-message layout + SHA256 (+ ed25519 signature over the hash).", + "vectors": [ + { + "name": "transfer basic", + "domain": "transfer", + "sourceSeed": 17, + "destinationSeed": 34, + "amount": "123456789", + "nonceSeed": 51, + "nonceValueSeed": 68, + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "message": "7472616e736665721111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222215cd5b070000000033333333333333333333333333333333333333333333333333333333333333334444444444444444444444444444444444444444444444444444444444444444", + "sha256": "01cf89d76b698052aa17e9df85978f433f40f52a1841232855927a2c603443d0", + "signature": "d91ba74e4706216f404547d92bf306be7c62a05f5b660090e90ad04f533db2b63b151f1ba1729673f9cb6e40b2a83e652551a04001d35344a0b879fdd0c47208" + }, + { + "name": "transfer zero", + "domain": "transfer", + "sourceSeed": 17, + "destinationSeed": 34, + "amount": "0", + "nonceSeed": 51, + "nonceValueSeed": 68, + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "message": "7472616e7366657211111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222000000000000000033333333333333333333333333333333333333333333333333333333333333334444444444444444444444444444444444444444444444444444444444444444", + "sha256": "28fad81ba8ab641b7ab912b98b53b9b8e2e7737099dbfe73433f5800fd790ef3", + "signature": "9d25daa0a2eecfde9c7fb2646f2cd9bd144aa43671a25f630dafee285720cbbde64edd593cf79084ab61a545d48685843d108cac50b52ec216ebab323e3f1b03" + }, + { + "name": "transfer max u64", + "domain": "transfer", + "sourceSeed": 170, + "destinationSeed": 187, + "amount": "18446744073709551615", + "nonceSeed": 204, + "nonceValueSeed": 221, + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "message": "7472616e73666572aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbffffffffffffffffccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "sha256": "f304f0a569a8b1c8b95d77bfa47ae54f2a76a7d95331ca7d112861cfd4772613", + "signature": "cc72c4fc8d63ca6767369f74c6813a9e8030525b1da7883b7a57d204c488339a4d9e0f64db09f279108aef2cbd3e304ae975529d989f9bb69d473fffb2113800" + }, + { + "name": "withdraw", + "domain": "withdraw_and_close", + "sourceSeed": 17, + "destinationSeed": 34, + "amount": null, + "nonceSeed": 51, + "nonceValueSeed": 68, + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "message": "77697468647261775f616e645f636c6f73651111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333334444444444444444444444444444444444444444444444444444444444444444", + "sha256": "5cf4168d85f8fb7ae6c3638db5a84d2c6e8a79e7e5c862b8fe4ce0a3ef9039e5", + "signature": "57ae42fb5fb19b9cc53f76826ce25040e0edcb28577c1a19d0e4016407a01a11e293a30a3811b05e0f4244abfcde1fc2914eacadc198b1419a318afb12adf205" + } + ] +} diff --git a/test-vectors/curve.json b/test-vectors/curve.json new file mode 100644 index 0000000000..841ddf1ae2 --- /dev/null +++ b/test-vectors/curve.json @@ -0,0 +1,97 @@ +{ + "algorithm": "discrete-bonding-curve", + "units": "currentSupply & tokens in whole tokens; spotPrice & value in USDC (18-dp fixed point on-chain)", + "note": "tokensToValue is exact integer arithmetic on the shared u128 tables; ground truth = Rust api/curve.rs.", + "vectors": [ + { + "name": "supply=0 tokens=50", + "note": "within single step", + "currentSupply": 0, + "tokens": 50, + "spotPrice": "0.01", + "value": "0.5", + "valueScaled": "500000000000000000" + }, + { + "name": "supply=0 tokens=100", + "note": "exact step boundary", + "currentSupply": 0, + "tokens": 100, + "spotPrice": "0.01", + "value": "1", + "valueScaled": "1000000000000000000" + }, + { + "name": "supply=50 tokens=50", + "note": "start mid-step, end on boundary (zero end-partial)", + "currentSupply": 50, + "tokens": 50, + "spotPrice": "0.01", + "value": "0.5", + "valueScaled": "500000000000000000" + }, + { + "name": "supply=50 tokens=150", + "note": "partial start + full step + boundary end", + "currentSupply": 50, + "tokens": 150, + "spotPrice": "0.01", + "value": "1.5000877213746469", + "valueScaled": "1500087721374646900" + }, + { + "name": "supply=75 tokens=350", + "note": "multi-step with both partials (Rust test)", + "currentSupply": 75, + "tokens": 350, + "spotPrice": "0.01", + "value": "3.500614091946595975", + "valueScaled": "3500614091946595975" + }, + { + "name": "supply=0 tokens=200", + "note": "two full steps (cumulative subtraction)", + "currentSupply": 0, + "tokens": 200, + "spotPrice": "0.01", + "value": "2.0000877213746469", + "valueScaled": "2000087721374646900" + }, + { + "name": "supply=99 tokens=1", + "note": "cross a step boundary buying 1", + "currentSupply": 99, + "tokens": 1, + "spotPrice": "0.01", + "value": "0.01", + "valueScaled": "10000000000000000" + }, + { + "name": "supply=100 tokens=1", + "note": "exactly at boundary, buy 1 (single step)", + "currentSupply": 100, + "tokens": 1, + "spotPrice": "0.010000877213746469", + "value": "0.010000877213746469", + "valueScaled": "10000877213746469" + }, + { + "name": "supply=1000000 tokens=500", + "note": "high supply: cumulative entries exceed u64 (iOS slow path)", + "currentSupply": 1000000, + "tokens": 500, + "spotPrice": "0.024040991835086708", + "value": "12.0226050113995003", + "valueScaled": "12022605011399500300" + }, + { + "name": "supply=20999900 tokens=100", + "note": "final step near max supply (21,000,000)", + "currentSupply": 20999900, + "tokens": 100, + "spotPrice": "999912.28630835324063318", + "value": "99991228.630835324063318", + "valueScaled": "99991228630835324063318000" + } + ] +} diff --git a/test-vectors/curve_fractional.json b/test-vectors/curve_fractional.json new file mode 100644 index 0000000000..e4507cd960 --- /dev/null +++ b/test-vectors/curve_fractional.json @@ -0,0 +1,49 @@ +{ + "algorithm": "discrete-bonding-curve-fractional", + "units": "currentSupply & tokens are fractional whole-token decimal strings; value in USDC", + "note": "Exact rational reference. Exercises the sell-path fractional arithmetic + rounding edges.", + "vectors": [ + { + "name": "frac within-step", + "note": "fractional tokens inside step 0 (regressed Android)", + "currentSupply": "0", + "tokens": "12.5", + "value": "0.125" + }, + { + "name": "frac boundary-cross", + "note": "fractional partial end after a full step", + "currentSupply": "0", + "tokens": "150.5", + "value": "1.5050442992941966845" + }, + { + "name": "frac both ends", + "note": "fractional start AND end partial", + "currentSupply": "50.25", + "tokens": "100.5", + "value": "1.00504451859763330175" + }, + { + "name": "sell-path multi-step", + "note": "value(0..new_supply): fractional, many steps", + "currentSupply": "0", + "tokens": "12345.6789012345", + "value": "124.122252404322416922567040156" + }, + { + "name": "high-supply fractional", + "note": "fractional crossing a boundary at high price", + "currentSupply": "999950.123456789", + "tokens": "100.987654321", + "value": "2.427738197118423368616824456" + }, + { + "name": "one-quark token", + "note": "1 token-quark (10^-10): within-step, sub-micro", + "currentSupply": "0", + "tokens": "0.0000000001", + "value": "0.000000000001" + } + ] +} diff --git a/test-vectors/ed25519.json b/test-vectors/ed25519.json new file mode 100644 index 0000000000..0f3ec59714 --- /dev/null +++ b/test-vectors/ed25519.json @@ -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" + } + ] +} diff --git a/test-vectors/gen_base58.py b/test-vectors/gen_base58.py new file mode 100644 index 0000000000..2c8fd1a8b0 --- /dev/null +++ b/test-vectors/gen_base58.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Generate authoritative Base58 (Bitcoin/Solana alphabet) test vectors. + +Base58 is deterministic; this is the reference encoder. Anchored to well-known values +(32 zero bytes -> the Solana system-program address "111...1", 32 ones) and cross-linked to the +ed25519 public keys (which ARE 32-byte Solana addresses). Both apps must reproduce encode+decode. +""" +import json + +ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +def b58encode(b: bytes) -> str: + n = int.from_bytes(b, "big") + s = "" + while n > 0: + n, r = divmod(n, 58) + s = ALPHABET[r] + s + pad = len(b) - len(b.lstrip(b"\x00")) # leading zero bytes -> leading '1's + return "1" * pad + s + +# (name, input bytes as hex) +CASES = [ + ("empty", ""), + ("single-zero", "00"), + ("zeros-32", "00" * 32), # -> Solana system program address + ("hello-world", "48656c6c6f20576f726c64"), # "Hello World" + ("leading-zeros", "0000287fb4cd"), + # cross-link: ed25519 rfc8032 public keys, i.e. real 32-byte Solana addresses + ("pubkey-rfc8032-1", "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"), + ("pubkey-rfc8032-3", "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"), +] + +vectors = [] +for name, hexin in CASES: + b = bytes.fromhex(hexin) + enc = b58encode(b) + vectors.append({"name": name, "bytes": hexin, "base58": enc}) + +# sanity anchor: 32 zero bytes must be the canonical Solana all-ones address +assert b58encode(bytes(32)) == "1" * 32, "base58 anchor failed" + +print(json.dumps({ + "algorithm": "base58", + "alphabet": ALPHABET, + "note": "Bitcoin/Solana Base58. Both apps must encode(bytes)==base58 and decode(base58)==bytes.", + "vectors": vectors, +}, indent=2)) diff --git a/test-vectors/gen_compact_message.py b/test-vectors/gen_compact_message.py new file mode 100644 index 0000000000..d12575e5ca --- /dev/null +++ b/test-vectors/gen_compact_message.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Authoritative intent-signing "compact message" vectors (SubmitIntent per-action signing). + +Both apps build the SAME pre-hash bytes for a transfer/withdraw action (verified from ActionTransfer/ +ActionType on both sides — identical field order, "transfer" domain, LITTLE-ENDIAN amount), then +sign SHA256(message) with the owner ed25519 key: + + transfer: "transfer" || source(32) || destination(32) || amount(LE8) || nonce(32) || nonceValue(32) + withdraw: "withdraw_and_close" || source(32) || destination(32) || nonce(32) || nonceValue(32) + signature = ed25519_sign( SHA256(message), owner ) + +The apps' compact-message code is internal, so the per-app tests rebuild the message from public +primitives (pubkey bytes + amount encoder) and assert the bytes + SHA256 match — the layout ORDER is +verified by source inspection on both platforms; this gates the byte-level composition (pubkey +serialization, LE amount) and SHA256. The signature line is ed25519 over the hash — already gated by +ed25519.json — and is included here for completeness / the KMP reference. +""" +import json, hashlib +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +def pk(seed: int) -> bytes: + return bytes([seed]) * 32 + +SIGNER_SEED = bytes([0x01]) * 32 +_owner = Ed25519PrivateKey.from_private_bytes(SIGNER_SEED) + +def build(domain: str, source: int, dest: int, amount, nonce: int, nonce_value: int) -> bytes: + msg = domain.encode("utf-8") + pk(source) + pk(dest) + if amount is not None: + msg += int(amount).to_bytes(8, "little") # LE8, matching both apps + msg += pk(nonce) + pk(nonce_value) + return msg + +CASES = [ + ("transfer basic", "transfer", 0x11, 0x22, 123456789, 0x33, 0x44), + ("transfer zero", "transfer", 0x11, 0x22, 0, 0x33, 0x44), + ("transfer max u64", "transfer", 0xAA, 0xBB, (1 << 64) - 1, 0xCC, 0xDD), + ("withdraw", "withdraw_and_close", 0x11, 0x22, None, 0x33, 0x44), +] + +vectors = [] +for name, domain, s, d, amt, n, nv in CASES: + msg = build(domain, s, d, amt, n, nv) + digest = hashlib.sha256(msg).digest() + sig = _owner.sign(digest) + vectors.append({ + "name": name, + "domain": domain, + "sourceSeed": s, "destinationSeed": d, + "amount": None if amt is None else str(amt), + "nonceSeed": n, "nonceValueSeed": nv, + "signerSeed": SIGNER_SEED.hex(), + "message": msg.hex(), + "sha256": digest.hex(), + "signature": sig.hex(), + }) + +print(json.dumps({ + "algorithm": "intent-compact-message", + "note": "transfer/withdraw compact-message layout + SHA256 (+ ed25519 signature over the hash).", + "vectors": vectors, +}, indent=2)) diff --git a/test-vectors/gen_curve.py b/test-vectors/gen_curve.py new file mode 100644 index 0000000000..72053fb0fd --- /dev/null +++ b/test-vectors/gen_curve.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Generate authoritative discrete-bonding-curve vectors. + +Ground truth = the on-chain Rust curve (flipcash-program/api). Both apps load the SAME binary tables +(discrete_pricing_table.bin / discrete_cumulative_table.bin, bit-identical SHA-256), which are +generated from the Rust table.rs. For WHOLE-TOKEN inputs `tokensToValue` is EXACT integer arithmetic +on the raw u128 table values (18-decimal fixed point) — no rounding — so we replicate it precisely +here and anchor to the documented Rust unit-test expectations. + +tokensToValue(S, T): + start = S//100 ; end = (S+T)//100 + if start == end: raw = price[start] * T + else: raw = price[start]*((start+1)*100 - S) + + (cumul[end] - cumul[start+1]) # full middle steps + + price[end]*((S+T) - end*100) # partial end step + value_usdc = raw / 10**18 (exact; denominator is a power of ten) +""" +import json, struct + +SCALE = 10 ** 18 +STEP = 100 +# Both apps read the identical file; use the iOS copy as the shared source. +BIN_DIR = "../code-ios-app/FlipcashCore/Sources/FlipcashCore/Resources" + +def load_table(name): + with open(f"{BIN_DIR}/{name}.bin", "rb") as f: + data = f.read() + n = len(data) // 16 + out = [0] * n + for i in range(n): + low, high = struct.unpack_from(" fractional tokens). +- iOS applies its `Rounding(.toNearestOrEven, 50)` context to `endSupply - endStepBoundary` and the + partial multiplies; Android uses exact `BigDecimal.subtract` / 50-digit HALF_EVEN. + +Reference here is EXACT rational arithmetic (`fractions.Fraction`) — the true mathematical value. +All chosen inputs are quark-aligned (<=10 decimals), so the exact value fits well within 50 significant +digits; both apps' BigDecimal (HALF_EVEN, 50) should therefore reproduce it EXACTLY. If either rounds +away from this value, the gate fails and the divergence is caught. +""" +import json, struct +from fractions import Fraction +from decimal import Decimal, getcontext + +getcontext().prec = 120 +SCALE = 10 ** 18 +STEP = 100 +BIN_DIR = "../code-ios-app/FlipcashCore/Sources/FlipcashCore/Resources" + +def load_table(name): + with open(f"{BIN_DIR}/{name}.bin", "rb") as f: + data = f.read() + return [((h << 64) | l) for l, h in + (struct.unpack_from(" Fraction: + start = int(S // STEP) # floor (S >= 0) + end = int((S + T) // STEP) + if start == end: + return Fraction(price[start]) * T + partial_start = Fraction((start + 1) * STEP) - S + partial_end = (S + T) - Fraction(end * STEP) + middle = Fraction(cumul[end] - cumul[start + 1]) + return Fraction(price[start]) * partial_start + middle + Fraction(price[end]) * partial_end + +def frac_to_str(fr: Fraction) -> str: + d = Decimal(fr.numerator) / Decimal(fr.denominator) # exact: denominator is a power of ten + return format(d.normalize(), "f") + +# (name, currentSupply, tokens, why). Both within-step and multi-step fractional cases. +# The within-step cases (12.5, one-quark) previously exposed an Android bug (BigDecimal `==` scale +# sensitivity in the startStep==endStep check) that returned a negative value; fixed in +# DiscreteBondingCurve.kt (compareTo), so they're back in the gate. +CASES = [ + ("frac within-step", "0", "12.5", "fractional tokens inside step 0 (regressed Android)"), + ("frac boundary-cross", "0", "150.5", "fractional partial end after a full step"), + ("frac both ends", "50.25", "100.5", "fractional start AND end partial"), + ("sell-path multi-step", "0", "12345.6789012345", "value(0..new_supply): fractional, many steps"), + ("high-supply fractional", "999950.123456789","100.987654321", "fractional crossing a boundary at high price"), + ("one-quark token", "0", "0.0000000001", "1 token-quark (10^-10): within-step, sub-micro"), +] + +vectors = [] +for name, s_str, t_str, why in CASES: + S, T = Fraction(s_str), Fraction(t_str) + raw = ttv_raw(S, T) + vectors.append({ + "name": name, + "note": why, + "currentSupply": s_str, # decimal strings (fractional) -> use the BigDecimal overloads + "tokens": t_str, + "value": frac_to_str(raw / SCALE), + }) + +print(json.dumps({ + "algorithm": "discrete-bonding-curve-fractional", + "units": "currentSupply & tokens are fractional whole-token decimal strings; value in USDC", + "note": "Exact rational reference. Exercises the sell-path fractional arithmetic + rounding edges.", + "vectors": vectors, +}, indent=2)) diff --git a/test-vectors/gen_ed25519.py b/test-vectors/gen_ed25519.py new file mode 100644 index 0000000000..65fa91ea45 --- /dev/null +++ b/test-vectors/gen_ed25519.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Generate authoritative ed25519 test vectors (RFC 8032 standard, via `cryptography`). + +Each vector: a 32-byte seed + a message -> the standard public key and signature. +Both apps' ed25519 must reproduce these; any mismatch is a real divergence. +Includes the RFC 8032 s7.1 vectors (as a correctness anchor) plus a few app-shaped cases. +""" +import json +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +# (name, seed_hex, message_hex). RFC 8032 uses the 32-byte secret as the seed. +CASES = [ + # RFC 8032 Section 7.1 anchors (authoritative). + ("rfc8032-test1", "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", ""), + ("rfc8032-test2", "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6f8", "72"), + ("rfc8032-test3", "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", "af82"), + # App-shaped: fixed seeds, Solana-ish 32-byte and larger messages (deterministic, no RNG). + ("zero-seed-empty", "0000000000000000000000000000000000000000000000000000000000000000", ""), + ("zero-seed-32b", "0000000000000000000000000000000000000000000000000000000000000000", + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"), + ("seed-ff-tx", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0100000000000000" + "de" * 64), # 8-byte header + 64-byte pseudo-message +] + +out = [] +for name, seed_hex, msg_hex in CASES: + seed = bytes.fromhex(seed_hex) + msg = bytes.fromhex(msg_hex) + sk = Ed25519PrivateKey.from_private_bytes(seed) + pub = sk.public_key().public_bytes_raw() + sig = sk.sign(msg) + out.append({ + "name": name, + "seed": seed_hex, + "message": msg_hex, + "publicKey": pub.hex(), + "signature": sig.hex(), + }) + +doc = { + "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": out, +} +print(json.dumps(doc, indent=2)) diff --git a/test-vectors/gen_slip10.py b/test-vectors/gen_slip10.py new file mode 100644 index 0000000000..a9b295f36f --- /dev/null +++ b/test-vectors/gen_slip10.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Generate authoritative BIP39 + SLIP-0010 (ed25519) derivation vectors. + +Mirrors what both apps do (verified from Derive.kt/Derive.swift + MnemonicCode/Mnemonic): + 1. BIP39 seed = PBKDF2-HMAC-SHA512(mnemonic sentence, "mnemonic"+passphrase, 2048, 64 bytes) + 2. SLIP-0010 = master HMAC-SHA512("ed25519 seed", seed); then per index CKDPriv with EVERY index + FORCE-HARDENED (apps add 0x80000000 unconditionally, ignoring the path's ' flag) + 3. account key = ed25519 keypair whose seed is the 32-byte derived key +Anchored to the official BIP39 and SLIP-0010 test vectors so "matches fixture" == "correct". +""" +import hashlib, hmac, json +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +HARDENED = 0x80000000 +B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +def bip39_seed(mnemonic: str, passphrase: str = "") -> bytes: + return hashlib.pbkdf2_hmac("sha512", mnemonic.encode("utf-8"), + ("mnemonic" + passphrase).encode("utf-8"), 2048, 64) + +def slip10_master(seed: bytes): + I = hmac.new(b"ed25519 seed", seed, hashlib.sha512).digest() + return I[:32], I[32:] + +def slip10_ckd(key: bytes, chain: bytes, index: int): # index already includes hardened bit + I = hmac.new(chain, b"\x00" + key + index.to_bytes(4, "big"), hashlib.sha512).digest() + return I[:32], I[32:] + +def parse_path(p: str): # "m/44'/501'/0'/0'/7665'/0" -> [44,501,0,0,7665,0] + return [int(x.replace("'", "")) for x in p.split("/")[1:]] + +def derive_key(seed: bytes, path: str) -> bytes: + key, chain = slip10_master(seed) + for v in parse_path(path): + key, chain = slip10_ckd(key, chain, HARDENED + v) # FORCE-hardened, like the apps + return key + +def b58encode(b: bytes) -> str: + n = int.from_bytes(b, "big"); s = "" + while n > 0: + n, r = divmod(n, 58); s = B58[r] + s + return "1" * (len(b) - len(b.lstrip(b"\x00"))) + s + +def pub_of(dk: bytes) -> bytes: + return Ed25519PrivateKey.from_private_bytes(dk).public_key().public_bytes_raw() + +# ---- anchors: fail loudly if the reference drifts from the standards ---- +# BIP39 (Trezor vector, empty passphrase) +_ab = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +assert bip39_seed(_ab).hex().startswith("5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1"), "BIP39 anchor failed" +# SLIP-0010 ed25519 official vector, seed 000102...0f +_k, _c = slip10_master(bytes.fromhex("000102030405060708090a0b0c0d0e0f")) +assert _k.hex() == "2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7", "SLIP-0010 master anchor failed" +_k0, _c0 = slip10_ckd(_k, _c, HARDENED + 0) +assert _k0.hex() == "68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3", "SLIP-0010 m/0' anchor failed" + +# ---- app vectors: known valid mnemonics, empty passphrase, real app paths ---- +MNEMONICS = { + "abandon-x11-about": _ab, + "legal-winner": "legal winner thank year wave sausage worth useful legal winner thank yellow", +} +PATHS = ["m/44'/501'/0'/0'", "m/44'/501'/0'/0'/7665'/0", "m/44'/501'/0'/0'/2335'/5"] + +vectors = [] +for mname, mnemonic in MNEMONICS.items(): + seed = bip39_seed(mnemonic) + for path in PATHS: + dk = derive_key(seed, path) + pub = pub_of(dk) + vectors.append({ + "name": f"{mname} {path}", + "mnemonic": mnemonic, + "passphrase": "", + "path": path, + "seedBip39": seed.hex(), + "derivedKey": dk.hex(), # 32-byte SLIP-0010 key == ed25519 seed + "publicKey": pub.hex(), + "address": b58encode(pub), # Solana address + }) + +print(json.dumps({ + "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": vectors, +}, indent=2)) diff --git a/test-vectors/gen_solana_message.py b/test-vectors/gen_solana_message.py new file mode 100644 index 0000000000..3f818130c3 --- /dev/null +++ b/test-vectors/gen_solana_message.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Authoritative Solana legacy-message serialization vectors. + +Implements the canonical legacy-message wire format that both apps' LegacyMessage.encode() must +produce (verified from LegacyMessage.swift / LegacyMessage.kt): + + message = header(3B) || shortvec(account_pubkeys) || recent_blockhash(32B) || shortvec(instructions) + header = [numRequiredSignatures, numReadonlySigners, numReadonlyNonSigners] + account order: payer first, then signers-before-nonsigners, writable-before-readonly, lex(pubkey) + compiled instruction = [programIndex] || shortvec(accountIndexes) || shortvec(data) + shortvec length = compact-u16 (7 bits/byte, high bit = continuation) + +Inputs are fixed (seed-byte pubkeys, zero blockhash) so both apps reconstruct the same message and +assert byte-equality against these expected bytes. +""" +import json + +def compact_u16(n: int) -> bytes: + out = bytearray() + while True: + b = n & 0x7F + n >>= 7 + if n: + out.append(b | 0x80) + else: + out.append(b) + return bytes(out) + +def shortvec(items): # items: list[bytes] + body = b"".join(items) + return compact_u16(len(items)) + body + +def pubkey(seed: int) -> bytes: + return bytes([seed]) * 32 + +# role -> (isSigner, isWritable, isPayer) +ROLES = { + "payer": (True, True, True), + "writable": (False, True, False), + "writable-signer": (True, True, False), + "readonly": (False, False, False), + "readonly-signer": (True, False, False), + "readonly-program": (False, False, False), +} + +def build_message(accounts, blockhash_seed, instructions): + # accounts: list of (seed, role). instructions: list of (program_seed, [account_seeds], data_bytes) + metas = [(pubkey(s), *ROLES[r]) for s, r in accounts] + # canonical sort: payer first, signer, writable, then lex by pubkey + metas.sort(key=lambda m: (not m[3], not m[1], not m[2], m[0])) + order = [m[0] for m in metas] # sorted pubkeys + + num_sigs = sum(1 for m in metas if m[1]) + ro_signers = sum(1 for m in metas if m[1] and not m[2]) + ro_nonsigners = sum(1 for m in metas if not m[1] and not m[2]) + header = bytes([num_sigs, ro_signers, ro_nonsigners]) + + compiled = [] + for prog_seed, acc_seeds, data in instructions: + prog_index = order.index(pubkey(prog_seed)) + acc_indexes = bytes(order.index(pubkey(s)) for s in acc_seeds) + compiled.append(bytes([prog_index]) + shortvec([bytes([b]) for b in acc_indexes]) + shortvec([bytes([b]) for b in data])) + + message = header + shortvec(order) + pubkey(blockhash_seed) + shortvec(compiled) + return header, message + +VECTORS_SPEC = [ + { + "name": "single instruction, 4 accounts", + "accounts": [(1, "payer"), (2, "writable"), (3, "readonly"), (9, "readonly-program")], + "blockhashSeed": 0, + "instructions": [(9, [1, 2, 3], [1, 2, 3])], + }, + { + "name": "minimal: payer + program, empty data", + "accounts": [(1, "payer"), (9, "readonly-program")], + "blockhashSeed": 7, + "instructions": [(9, [1], [])], + }, + { + "name": "readonly co-signer + two instructions", + "accounts": [(1, "payer"), (4, "readonly-signer"), (2, "writable"), (9, "readonly-program")], + "blockhashSeed": 0, + "instructions": [(9, [1, 2], [255]), (9, [4, 1], [16, 32])], + }, +] + +vectors = [] +for spec in VECTORS_SPEC: + header, message = build_message(spec["accounts"], spec["blockhashSeed"], spec["instructions"]) + vectors.append({ + "name": spec["name"], + "accounts": [{"seed": s, "role": r} for s, r in spec["accounts"]], + "blockhashSeed": spec["blockhashSeed"], + "instructions": [ + {"programSeed": p, "accountSeeds": a, "data": bytes(d).hex()} for p, a, d in spec["instructions"] + ], + "expectedHeader": header.hex(), + "expectedMessage": message.hex(), + }) + +print(json.dumps({ + "algorithm": "solana-legacy-message", + "note": "Pubkey(seed) = 32 bytes each == seed. Both apps build the message from these inputs and " + "must reproduce expectedMessage from LegacyMessage.encode().", + "vectors": vectors, +}, indent=2)) diff --git a/test-vectors/slip10.json b/test-vectors/slip10.json new file mode 100644 index 0000000000..fbb0e3331e --- /dev/null +++ b/test-vectors/slip10.json @@ -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" + } + ] +} diff --git a/test-vectors/solana_message.json b/test-vectors/solana_message.json new file mode 100644 index 0000000000..36f9651f6b --- /dev/null +++ b/test-vectors/solana_message.json @@ -0,0 +1,108 @@ +{ + "algorithm": "solana-legacy-message", + "note": "Pubkey(seed) = 32 bytes each == seed. Both apps build the message from these inputs and must reproduce expectedMessage from LegacyMessage.encode().", + "vectors": [ + { + "name": "single instruction, 4 accounts", + "accounts": [ + { + "seed": 1, + "role": "payer" + }, + { + "seed": 2, + "role": "writable" + }, + { + "seed": 3, + "role": "readonly" + }, + { + "seed": 9, + "role": "readonly-program" + } + ], + "blockhashSeed": 0, + "instructions": [ + { + "programSeed": 9, + "accountSeeds": [ + 1, + 2, + 3 + ], + "data": "010203" + } + ], + "expectedHeader": "010002", + "expectedMessage": "010002040101010101010101010101010101010101010101010101010101010101010101020202020202020202020202020202020202020202020202020202020202020203030303030303030303030303030303030303030303030303030303030303030909090909090909090909090909090909090909090909090909090909090909000000000000000000000000000000000000000000000000000000000000000001030300010203010203" + }, + { + "name": "minimal: payer + program, empty data", + "accounts": [ + { + "seed": 1, + "role": "payer" + }, + { + "seed": 9, + "role": "readonly-program" + } + ], + "blockhashSeed": 7, + "instructions": [ + { + "programSeed": 9, + "accountSeeds": [ + 1 + ], + "data": "" + } + ], + "expectedHeader": "010001", + "expectedMessage": "010001020101010101010101010101010101010101010101010101010101010101010101090909090909090909090909090909090909090909090909090909090909090907070707070707070707070707070707070707070707070707070707070707070101010000" + }, + { + "name": "readonly co-signer + two instructions", + "accounts": [ + { + "seed": 1, + "role": "payer" + }, + { + "seed": 4, + "role": "readonly-signer" + }, + { + "seed": 2, + "role": "writable" + }, + { + "seed": 9, + "role": "readonly-program" + } + ], + "blockhashSeed": 0, + "instructions": [ + { + "programSeed": 9, + "accountSeeds": [ + 1, + 2 + ], + "data": "ff" + }, + { + "programSeed": 9, + "accountSeeds": [ + 4, + 1 + ], + "data": "1020" + } + ], + "expectedHeader": "020101", + "expectedMessage": "0201010401010101010101010101010101010101010101010101010101010101010101010404040404040404040404040404040404040404040404040404040404040404020202020202020202020202020202020202020202020202020202020202020209090909090909090909090909090909090909090909090909090909090909090000000000000000000000000000000000000000000000000000000000000000020302000201ff03020100021020" + } + ] +}