From 2bd8ab8c833483ea427aa662086eb9a336460d20 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 18:18:29 -0400 Subject: [PATCH 01/26] refactor(keys): replace javaClass equality check with KClass javaClass does not compile in a commonMain source set. this::class/other::class is the multiplatform-safe equivalent and preserves the exact-type semantics: same bytes across two different KeyType subclasses (e.g. Key64 vs Signature) still compare unequal. Adds a KeyTest case for that cross-subclass comparison, which the existing suite exercised implicitly but never asserted. --- .../kotlin/com/getcode/solana/keys/Key.kt | 2 +- .../kotlin/com/getcode/solana/keys/KeyTest.kt | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt index 864e11a62d..36de85ac6b 100644 --- a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt @@ -13,7 +13,7 @@ abstract class KeyType(bytes: List) { val byteArray = bytes.toByteArray() override fun equals(other: Any?): Boolean { if (this === other) return true - if (javaClass != other?.javaClass) return false + if (other == null || this::class != other::class) return false other as KeyType diff --git a/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/KeyTest.kt b/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/KeyTest.kt index 761405d5a8..ecf12bb1ec 100644 --- a/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/KeyTest.kt +++ b/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/KeyTest.kt @@ -2,6 +2,7 @@ package com.getcode.solana.keys import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertTrue class KeyTest { @@ -79,4 +80,25 @@ class KeyTest { assertEquals(zero1.base58(), zero2.base58()) } + + @Test + fun `KeyType equals rejects same bytes across different concrete subclasses`() { + // Key64 and Signature both hold their bytes via KeyType.equals directly (neither + // overrides equals), so this is the case the exact-type check in KeyType.equals guards: + // same size, same bytes, different concrete class must not be equal, in either direction. + val bytes = ByteArray(LENGTH_64) { it.toByte() }.toList() + val key64 = Key64(bytes) + val signature = Signature(bytes) + + assertNotEquals(key64, signature) + assertNotEquals(signature, key64) + } + + @Test + fun `KeyType equals accepts same bytes for the same concrete subclass`() { + val bytes = ByteArray(LENGTH_64) { it.toByte() }.toList() + + assertEquals(Key64(bytes), Key64(bytes)) + assertEquals(Signature(bytes), Signature(bytes)) + } } From 8480314a24d7a4db8bec12ff2205599133d95350 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 18:21:54 -0400 Subject: [PATCH 02/26] refactor(solana): move DataSlice into libs:encryption:utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataSlice was internal to :services:opencode, so extracting the Solana encoding layer in a later phase would break the six call sites outside that package (ComputeBudgetProgram_*, InstructionType, SwapValidatorProgram, TimelockProgram, OpenCodePayload, PayloadKind). It has no Solana-specific logic — generic byte-list slicing — so it moves to commonMain in :libs:encryption:utils, which already builds for Android and all five Apple targets, and becomes a public object. :services:opencode already depended on that module, so no build.gradle.kts change was needed. --- .../src/commonMain/kotlin/com/getcode}/utils/DataSlice.kt | 4 ++-- .../com/getcode/opencode/internal/solana/ShortVec.kt | 2 +- .../programs/ComputeBudgetProgram_SetComputeUnitLimit.kt | 2 +- .../programs/ComputeBudgetProgram_SetComputeUnitPrice.kt | 2 +- .../opencode/internal/solana/programs/InstructionType.kt | 4 ++-- .../internal/solana/programs/SwapValidatorProgram.kt | 2 +- .../opencode/internal/solana/programs/TimelockProgram.kt | 2 +- .../com/getcode/opencode/model/core/OpenCodePayload.kt | 4 ++-- .../kotlin/com/getcode/opencode/model/core/PayloadKind.kt | 4 ++-- .../kotlin/com/getcode/opencode/solana/Instruction.kt | 4 ++-- .../kotlin/com/getcode/opencode/solana/LegacyMessage.kt | 6 +++--- .../main/kotlin/com/getcode/opencode/solana/Message.kt | 2 +- .../kotlin/com/getcode/opencode/solana/MessageHeader.kt | 2 +- .../com/getcode/opencode/solana/SolanaTransaction.kt | 4 ++-- .../com/getcode/opencode/solana/VersionedMessage.kt | 8 ++++---- 15 files changed, 26 insertions(+), 26 deletions(-) rename {services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana => libs/encryption/utils/src/commonMain/kotlin/com/getcode}/utils/DataSlice.kt (95%) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/utils/DataSlice.kt b/libs/encryption/utils/src/commonMain/kotlin/com/getcode/utils/DataSlice.kt similarity index 95% rename from services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/utils/DataSlice.kt rename to libs/encryption/utils/src/commonMain/kotlin/com/getcode/utils/DataSlice.kt index e1a0788ce8..bedfdc15eb 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/utils/DataSlice.kt +++ b/libs/encryption/utils/src/commonMain/kotlin/com/getcode/utils/DataSlice.kt @@ -1,8 +1,8 @@ -package com.getcode.opencode.internal.solana.utils +package com.getcode.utils import kotlin.math.min -internal object DataSlice { +object DataSlice { data class ByteListConsume(val consumed: List, val remaining: List) fun List.canConsume(length: Int): Boolean { diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt index ae0a7bfb68..5a815b9b3a 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt @@ -1,6 +1,6 @@ package com.getcode.opencode.internal.solana -import com.getcode.opencode.internal.solana.utils.DataSlice.tail +import com.getcode.utils.DataSlice.tail import java.io.ByteArrayInputStream internal object ShortVec { diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/ComputeBudgetProgram_SetComputeUnitLimit.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/ComputeBudgetProgram_SetComputeUnitLimit.kt index d33f49522c..a34185402d 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/ComputeBudgetProgram_SetComputeUnitLimit.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/ComputeBudgetProgram_SetComputeUnitLimit.kt @@ -1,6 +1,6 @@ package com.getcode.opencode.internal.solana.programs -import com.getcode.opencode.internal.solana.utils.DataSlice.consume +import com.getcode.utils.DataSlice.consume import com.getcode.opencode.solana.Instruction import com.getcode.utils.byteArrayToInt import com.getcode.utils.intToByteArray diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/ComputeBudgetProgram_SetComputeUnitPrice.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/ComputeBudgetProgram_SetComputeUnitPrice.kt index e20c75d9e8..2999c75de7 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/ComputeBudgetProgram_SetComputeUnitPrice.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/ComputeBudgetProgram_SetComputeUnitPrice.kt @@ -1,6 +1,6 @@ package com.getcode.opencode.internal.solana.programs -import com.getcode.opencode.internal.solana.utils.DataSlice.consume +import com.getcode.utils.DataSlice.consume import com.getcode.opencode.solana.Instruction import com.getcode.utils.byteArrayToLong import com.getcode.utils.longToByteArray diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/InstructionType.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/InstructionType.kt index 9a9116ff16..f04e3652ee 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/InstructionType.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/InstructionType.kt @@ -1,7 +1,7 @@ package com.getcode.opencode.internal.solana.programs -import com.getcode.opencode.internal.solana.utils.DataSlice -import com.getcode.opencode.internal.solana.utils.DataSlice.consume +import com.getcode.utils.DataSlice +import com.getcode.utils.DataSlice.consume import com.getcode.opencode.solana.Instruction import com.getcode.solana.keys.PublicKey diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/SwapValidatorProgram.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/SwapValidatorProgram.kt index cfb0d7f2ea..44c7c29659 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/SwapValidatorProgram.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/SwapValidatorProgram.kt @@ -1,6 +1,6 @@ package com.getcode.opencode.internal.solana.programs -import com.getcode.opencode.internal.solana.utils.DataSlice.toLong +import com.getcode.utils.DataSlice.toLong import com.getcode.solana.keys.PublicKey import com.getcode.vendor.Base58 diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/TimelockProgram.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/TimelockProgram.kt index 7433836516..d328d50e6d 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/TimelockProgram.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/TimelockProgram.kt @@ -1,6 +1,6 @@ package com.getcode.opencode.internal.solana.programs -import com.getcode.opencode.internal.solana.utils.DataSlice.toLong +import com.getcode.utils.DataSlice.toLong import com.getcode.solana.keys.PublicKey import com.getcode.vendor.Base58 diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/OpenCodePayload.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/OpenCodePayload.kt index cb4604f248..e5d0ce4112 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/OpenCodePayload.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/OpenCodePayload.kt @@ -1,8 +1,8 @@ package com.getcode.opencode.model.core import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.opencode.internal.solana.utils.DataSlice.byteToUnsignedInt -import com.getcode.opencode.internal.solana.utils.DataSlice.suffix +import com.getcode.utils.DataSlice.byteToUnsignedInt +import com.getcode.utils.DataSlice.suffix import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.utils.deriveRendezvousKey diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadKind.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadKind.kt index 94ee2c1920..a94e67f028 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadKind.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadKind.kt @@ -1,7 +1,7 @@ package com.getcode.opencode.model.core -import com.getcode.opencode.internal.solana.utils.DataSlice.byteToUnsignedInt -import com.getcode.opencode.internal.solana.utils.DataSlice.suffix +import com.getcode.utils.DataSlice.byteToUnsignedInt +import com.getcode.utils.DataSlice.suffix import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import com.getcode.utils.byteArrayToLong diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/Instruction.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/Instruction.kt index 46544031dc..8eeaf1ecbe 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/Instruction.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/Instruction.kt @@ -1,8 +1,8 @@ package com.getcode.opencode.solana import com.getcode.opencode.internal.solana.ShortVec -import com.getcode.opencode.internal.solana.utils.DataSlice.consume -import com.getcode.opencode.internal.solana.utils.DataSlice.prefix +import com.getcode.utils.DataSlice.consume +import com.getcode.utils.DataSlice.prefix import com.getcode.solana.keys.AccountMeta import com.getcode.solana.keys.PublicKey import com.getcode.solana.keys.base58 diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/LegacyMessage.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/LegacyMessage.kt index bf3dc8b7d7..6a515d626f 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/LegacyMessage.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/LegacyMessage.kt @@ -1,9 +1,9 @@ package com.getcode.opencode.solana import com.getcode.opencode.internal.solana.ShortVec -import com.getcode.opencode.internal.solana.utils.DataSlice.chunk -import com.getcode.opencode.internal.solana.utils.DataSlice.consume -import com.getcode.opencode.internal.solana.utils.DataSlice.tail +import com.getcode.utils.DataSlice.chunk +import com.getcode.utils.DataSlice.consume +import com.getcode.utils.DataSlice.tail import com.getcode.solana.keys.AccountMeta import com.getcode.solana.keys.Hash import com.getcode.solana.keys.filterUniqueAccounts diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/Message.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/Message.kt index 032bc1b569..89d21634e7 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/Message.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/Message.kt @@ -1,7 +1,7 @@ package com.getcode.opencode.solana import com.getcode.opencode.internal.solana.model.MessageAddressLookupTable -import com.getcode.opencode.internal.solana.utils.DataSlice.byteToUnsignedInt +import com.getcode.utils.DataSlice.byteToUnsignedInt import com.getcode.solana.keys.Hash import com.getcode.solana.keys.PublicKey import kotlin.math.abs diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/MessageHeader.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/MessageHeader.kt index 12913358f9..1633bf7665 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/MessageHeader.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/MessageHeader.kt @@ -1,6 +1,6 @@ package com.getcode.opencode.solana -import com.getcode.opencode.internal.solana.utils.DataSlice.byteToUnsignedInt +import com.getcode.utils.DataSlice.byteToUnsignedInt open class MessageHeader( diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt index 299cd2fe8f..2ff44aa261 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt @@ -3,8 +3,8 @@ package com.getcode.opencode.solana import com.getcode.ed25519.Ed25519 import com.getcode.opencode.internal.solana.ShortVec import com.getcode.opencode.internal.solana.model.MessageAddressLookupTable -import com.getcode.opencode.internal.solana.utils.DataSlice.chunk -import com.getcode.opencode.internal.solana.utils.DataSlice.tail +import com.getcode.utils.DataSlice.chunk +import com.getcode.utils.DataSlice.tail import com.getcode.opencode.internal.solana.utils.printDiff import com.getcode.opencode.internal.solana.utils.printMatch import com.getcode.opencode.model.transactions.AddressLookupTable diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/VersionedMessage.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/VersionedMessage.kt index 7da96f7450..c337b8d513 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/VersionedMessage.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/VersionedMessage.kt @@ -2,10 +2,10 @@ package com.getcode.opencode.solana import com.getcode.opencode.internal.solana.ShortVec import com.getcode.opencode.internal.solana.model.MessageAddressLookupTable -import com.getcode.opencode.internal.solana.utils.DataSlice.byteToUnsignedInt -import com.getcode.opencode.internal.solana.utils.DataSlice.consume -import com.getcode.opencode.internal.solana.utils.DataSlice.prefix -import com.getcode.opencode.internal.solana.utils.DataSlice.tail +import com.getcode.utils.DataSlice.byteToUnsignedInt +import com.getcode.utils.DataSlice.consume +import com.getcode.utils.DataSlice.prefix +import com.getcode.utils.DataSlice.tail import com.getcode.solana.keys.Hash import com.getcode.solana.keys.LENGTH_32 import com.getcode.solana.keys.PublicKey From 47004032a46605f08cca5605b45db3cf42713f01 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 18:22:55 -0400 Subject: [PATCH 03/26] refactor(solana): drop ByteArrayInputStream from ShortVec.decodeLen java.io.ByteArrayInputStream does not compile in commonMain. decodeLen already reads one byte at a time and stops on the continuation bit, so it walks the input List with an index instead of wrapping it in a stream. Same byte-for-byte decode. --- .../opencode/internal/solana/ShortVec.kt | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt index 5a815b9b3a..5727711874 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt @@ -1,43 +1,30 @@ package com.getcode.opencode.internal.solana import com.getcode.utils.DataSlice.tail -import java.io.ByteArrayInputStream internal object ShortVec { /** * decodeLen decodes a ShortVec encoded length from the [input]. * - * @param input - the input stream that the length is encoded in + * @param input - the input list that the length is encoded in * @return - returns the decoded length of the ShortVec and Offset */ - private fun decodeLen(input: ByteArrayInputStream): Pair { + fun decodeLen(input: List): Pair> { var offset = 0 - val valBuf = ByteArray(1) var value = 0 while (true) { - input.read(valBuf) + val byte = input[offset] - value = value or (valBuf[0].toInt() and 0x7f shl (offset * 7)) + value = value or (byte.toInt() and 0x7f shl (offset * 7)) offset++ - if ((valBuf[0].toInt() and 0x80) == 0) { + if ((byte.toInt() and 0x80) == 0) { break } } - return Pair(value, offset) - } - - /** - * decodeLen decodes a ShortVec encoded length from the [input]. - * - * @param input - the input list that the length is encoded in - * @return - returns the decoded length of the ShortVec and Offset - */ - fun decodeLen(input: List): Pair> { - val l = decodeLen(input.toByteArray().inputStream()) - return Pair(l.first, input.tail(l.second)) + return Pair(value, input.tail(offset)) } From 8b0603d3dcaa17dc7b2959bae0d0eb0128df9c53 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 18:24:16 -0400 Subject: [PATCH 04/26] refactor(solana): drop trace() calls from VersionedMessage decode path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trace() reaches :libs:logging, which is Android-only. The ten calls in VersionedMessageV0.newInstance were decode-path diagnostics only — none of them affect control flow, every one sits right next to a `return null` that fires with or without it. Removed rather than replaced, since neither of them guards a branch a caller could act on. --- .../opencode/solana/VersionedMessage.kt | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/VersionedMessage.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/VersionedMessage.kt index c337b8d513..276283a178 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/VersionedMessage.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/VersionedMessage.kt @@ -9,8 +9,6 @@ import com.getcode.utils.DataSlice.tail import com.getcode.solana.keys.Hash import com.getcode.solana.keys.LENGTH_32 import com.getcode.solana.keys.PublicKey -import com.getcode.utils.TraceType -import com.getcode.utils.trace /** * Represents a Version 0 (V0) Solana transaction message. @@ -58,7 +56,6 @@ data class VersionedMessageV0( companion object { fun newInstance(data: List): VersionedMessageV0? { if (data.isEmpty()) { - trace(type = TraceType.Error, message = "data is empty") return null } @@ -69,7 +66,6 @@ data class VersionedMessageV0( payload = remainingPayload if (version.first().byteToUnsignedInt() != (MessageVersion.v0.ordinal + messageVersionSerializationOffset)) { - trace(type = TraceType.Error, message = "version is not v0") return null } // Decode Header (manually, without decompiling instructions) @@ -79,7 +75,6 @@ data class VersionedMessageV0( // Decode static account keys val (accountCount, accountData) = ShortVec.decodeLen(payload) - trace(type = TraceType.Process, message = "static account count: $accountCount") val staticKeys = accountData.chunked(LENGTH_32).mapNotNull { chunk -> runCatching { PublicKey(chunk) }.getOrNull() @@ -92,13 +87,11 @@ data class VersionedMessageV0( payload = remainingPayload2 val hash = runCatching { Hash(hashBytes) }.getOrNull() if (hash == null) { - trace(type = TraceType.Error, message = "failed to decode blockhash") return null } // Decode compiled instructions (without decompiling yet) val (instructionCount, instructionsData) = ShortVec.decodeLen(payload) - trace(type = TraceType.Process, message = "instruction count: $instructionCount") var remainingInstructionsData = instructionsData val compiledInstructions = mutableListOf() @@ -106,7 +99,6 @@ data class VersionedMessageV0( repeat(instructionCount) { val instruction = CompiledInstruction.fromList(remainingInstructionsData) if (instruction == null) { - trace(type = TraceType.Error, message = "failed to decode instruction") return null } @@ -124,7 +116,6 @@ data class VersionedMessageV0( repeat(altCount) { // public key if (remaining.count() < LENGTH_32) { - trace(type = TraceType.Error, message = "not enough data for lookup public key") return null } @@ -132,7 +123,6 @@ data class VersionedMessageV0( remaining = remaining.drop(LENGTH_32) val publicKey = runCatching { PublicKey(publicKeyData) }.getOrNull() if (publicKey == null) { - trace(type = TraceType.Error, message = "failed to decode lookup public key") return null } @@ -141,10 +131,6 @@ data class VersionedMessageV0( remaining = writableRemaining if (remaining.count() < writableIndexLength) { - trace( - type = TraceType.Error, - message = "not enough data for lookup writable indexes, need: $writableIndexLength, have: ${remaining.count()}" - ) return null } @@ -156,10 +142,6 @@ data class VersionedMessageV0( remaining = readonlyRemaining if (remaining.count() < readonlyIndexLength) { - trace( - type = TraceType.Error, - message = "not enough data for lookup readonly indexes, need: $readonlyIndexLength, have: ${remaining.count()}" - ) return null } From aebc5aefa1f653a078a6164be95d84e5d9b7c640 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 18:31:04 -0400 Subject: [PATCH 05/26] refactor(solana): push ByteString conversions out of key and transaction types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit com.google.protobuf.ByteString is a proto-boundary type; the classes that will move to commonMain (Signature, PublicKey, SolanaTransaction) shouldn't import it directly. Moved each ByteString entry point into its own file next to the type it serves: - Signature's `ByteString` constructor becomes a top-level pseudo-constructor function in ByteStringKeys.kt. Kotlin resolves a top-level function sharing a class's name alongside its real constructors, so `Signature(byteString)` call sites keep compiling unchanged. - PublicKey.fromByteString becomes an extension on PublicKey.Companion in the same file — no call sites exist for it today, but the call syntax `PublicKey.fromByteString(...)` would still resolve if one existed. - SolanaTransaction.fromBytes becomes an extension on SolanaTransaction.Companion in ByteStringSolanaTransaction.kt. Its one call site (StatefulSwapExecutor.kt) needed an added import for the now-external function, since extension functions aren't pulled in by importing the class they extend. libs:encryption:keys:test and services:opencode:test both pass, including SolanaMessageVectorTest and CompactMessageVectorTest. --- .../com/getcode/solana/keys/ByteStringKeys.kt | 20 +++++++++++++++++++ .../com/getcode/solana/keys/PublicKey.kt | 5 ----- .../kotlin/com/getcode/solana/keys/Types.kt | 3 --- .../network/executors/StatefulSwapExecutor.kt | 1 + .../solana/ByteStringSolanaTransaction.kt | 13 ++++++++++++ .../opencode/solana/SolanaTransaction.kt | 5 ----- 6 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/ByteStringKeys.kt create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/solana/ByteStringSolanaTransaction.kt diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/ByteStringKeys.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/ByteStringKeys.kt new file mode 100644 index 0000000000..c4ce46d402 --- /dev/null +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/ByteStringKeys.kt @@ -0,0 +1,20 @@ +package com.getcode.solana.keys + +import com.google.protobuf.ByteString + +/** + * `ByteString` is a proto-boundary type, not something the key/signature value types themselves + * should depend on. These sit in their own file, outside [Signature] and [PublicKey.Companion], + * so that neither type carries a `ByteString` import. + */ + +/** + * Pseudo-constructor for [Signature] from a proto [ByteString]. Kotlin resolves a top-level + * function sharing a class's name alongside that class's real constructors, so call sites written + * as `Signature(byteString)` keep compiling unchanged. + */ +fun Signature(byteString: ByteString): Signature = Signature(byteString.toByteArray().toList()) + +fun PublicKey.Companion.fromByteString(byteString: ByteString): PublicKey { + return PublicKey(byteString.toByteArray().toList()) +} diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt index 108222754f..1664159de9 100644 --- a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt @@ -4,7 +4,6 @@ import android.os.Parcel import android.os.Parcelable import com.getcode.utils.serializer.PublicKeyAsStringSerializer import com.getcode.vendor.Base58 -import com.google.protobuf.ByteString import kotlinx.serialization.Serializable @Serializable(with = PublicKeyAsStringSerializer::class) @@ -24,10 +23,6 @@ open class PublicKey(bytes: List) : Key32(bytes), Parcelable { return PublicKey(base58) } - fun fromByteString(byteString: ByteString): PublicKey { - return PublicKey(byteString.toByteArray().toList()) - } - val ZERO: PublicKey = PublicKey(zero.bytes) @JvmField diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt index 1fb5238104..4751e08d9b 100644 --- a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt @@ -1,7 +1,5 @@ package com.getcode.solana.keys -import com.google.protobuf.ByteString - typealias Seed16 = Key16 typealias Seed32 = Key32 @@ -11,7 +9,6 @@ typealias Checksum = Key32 typealias PrivateKey = Key64 class Signature(bytes: List): Key64(bytes) { - constructor(byteString: ByteString): this(byteString.toList()) companion object { val zero = Signature(ByteArray(LENGTH_64).toList()) } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt index 3781ce5302..d19c9be287 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt @@ -14,6 +14,7 @@ import com.getcode.opencode.model.transactions.SwapResult import com.getcode.opencode.model.transactions.SwapProgram import com.getcode.opencode.model.transactions.VerifiedSwapMetadata import com.getcode.opencode.solana.SolanaTransaction +import com.getcode.opencode.solana.fromBytes import com.getcode.opencode.solana.diff import com.getcode.services.opencode.BuildConfig import com.getcode.solana.keys.Signature diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/ByteStringSolanaTransaction.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/ByteStringSolanaTransaction.kt new file mode 100644 index 0000000000..a5eda06d3f --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/ByteStringSolanaTransaction.kt @@ -0,0 +1,13 @@ +package com.getcode.opencode.solana + +import com.google.protobuf.ByteString + +/** + * `ByteString` is a proto-boundary type. Kept out of [SolanaTransaction] itself so the class only + * depends on `ByteArray`/`List`; this extension is the one place that bridges to proto wire + * bytes. Call sites written as `SolanaTransaction.fromBytes(bytes)` keep compiling unchanged since + * an extension function on the companion object resolves the same way. + */ +fun SolanaTransaction.Companion.fromBytes(bytes: ByteString): SolanaTransaction? { + return fromList(bytes.toByteArray().toList()) +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt index 2ff44aa261..c2855c17d3 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt @@ -15,7 +15,6 @@ import com.getcode.solana.keys.PublicKey import com.getcode.solana.keys.Signature import com.getcode.solana.keys.base58 import com.getcode.solana.keys.filterUniqueAccounts -import com.google.protobuf.ByteString /* Signature: [64]byte @@ -115,10 +114,6 @@ data class SolanaTransaction(val message: Message, val signatures: List): SolanaTransaction? { val (signatureCount, payload) = ShortVec.decodeLen(list) From 5f7d43f8b8b7eb84d5894865d7db782b81580b5d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 19:06:00 -0400 Subject: [PATCH 06/26] refactor(keys): convert :libs:encryption:keys to Kotlin Multiplatform Move src/main/kotlin to commonMain (packages unchanged) and split Android-only pieces (ByteListSerializer, ByteStringKeys) into androidMain, so the module builds for android, iosArm64/iosX64/iosSimulatorArm64, and macosArm64/macosX64 ahead of the Solana-encoding sharing work. The bespoke build.gradle.kts replaces flipcash.android.library, so it has to explicitly apply org.jetbrains.kotlin.plugin.serialization: the old convention plugin applied it automatically, and without it @Serializable codegen for Mint/PublicKey silently falls back to reflection and throws at runtime. grpc-okhttp and grpc-kotlin were dependencies of the old module but nothing under src/ references gRPC; dropped rather than carried into the split. KeyType.base64()/base64Redacted() depended on the Android-only com.getcode.utils.encodeBase64, so they move to a new androidMain KeyBase64.kt. No production code calls them today, so this is a relocation rather than a behaviour change. --- libs/encryption/keys/build.gradle.kts | 60 ++++++++++++++----- .../com/getcode/solana/keys/ByteStringKeys.kt | 0 .../com/getcode/solana/keys/KeyBase64.kt | 14 +++++ .../utils/serializer/ByteListSerializer.kt | 0 .../com/getcode/solana/keys/AccountMeta.kt | 0 .../kotlin/com/getcode/solana/keys/Key.kt | 3 - .../com/getcode/solana/keys/MerkleProof.kt | 0 .../kotlin/com/getcode/solana/keys/Mint.kt | 0 .../com/getcode/solana/keys/PublicKey.kt | 0 .../kotlin/com/getcode/solana/keys/Types.kt | 0 .../utils/serializer/MintSerializer.kt | 0 .../utils/serializer/PublicKeySerializer.kt | 0 12 files changed, 59 insertions(+), 18 deletions(-) rename libs/encryption/keys/src/{main => androidMain}/kotlin/com/getcode/solana/keys/ByteStringKeys.kt (100%) create mode 100644 libs/encryption/keys/src/androidMain/kotlin/com/getcode/solana/keys/KeyBase64.kt rename libs/encryption/keys/src/{main => androidMain}/kotlin/com/getcode/utils/serializer/ByteListSerializer.kt (100%) rename libs/encryption/keys/src/{main => commonMain}/kotlin/com/getcode/solana/keys/AccountMeta.kt (100%) rename libs/encryption/keys/src/{main => commonMain}/kotlin/com/getcode/solana/keys/Key.kt (93%) rename libs/encryption/keys/src/{main => commonMain}/kotlin/com/getcode/solana/keys/MerkleProof.kt (100%) rename libs/encryption/keys/src/{main => commonMain}/kotlin/com/getcode/solana/keys/Mint.kt (100%) rename libs/encryption/keys/src/{main => commonMain}/kotlin/com/getcode/solana/keys/PublicKey.kt (100%) rename libs/encryption/keys/src/{main => commonMain}/kotlin/com/getcode/solana/keys/Types.kt (100%) rename libs/encryption/keys/src/{main => commonMain}/kotlin/com/getcode/utils/serializer/MintSerializer.kt (100%) rename libs/encryption/keys/src/{main => commonMain}/kotlin/com/getcode/utils/serializer/PublicKeySerializer.kt (100%) diff --git a/libs/encryption/keys/build.gradle.kts b/libs/encryption/keys/build.gradle.kts index 4e4c81f68c..95a27e5c9a 100644 --- a/libs/encryption/keys/build.gradle.kts +++ b/libs/encryption/keys/build.gradle.kts @@ -1,21 +1,51 @@ plugins { - alias(libs.plugins.flipcash.android.library) + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.kotlin.serialization) } -android { - namespace = "${Gradle.codeNamespace}.encryption.keys" -} - -dependencies { - implementation(project(":libs:encryption:base58")) - implementation(project(":libs:encryption:sha256")) - implementation(project(":libs:encryption:utils")) +kotlin { + android { + namespace = "${Gradle.codeNamespace}.encryption.keys" + compileSdk { + version = release(libs.versions.android.compileSdk.get().toInt()) { + minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() + } + } + minSdk = 29 + withHostTest {} + } - implementation(libs.protobuf.kotlin.lite) - implementation(libs.grpc.okhttp) - implementation(libs.grpc.kotlin) - implementation(libs.bundles.kotlinx.serialization) + iosArm64() + iosSimulatorArm64() + iosX64() + macosArm64() + macosX64() - testImplementation(kotlin("test")) - testImplementation(libs.robolectric) + sourceSets { + commonMain { + dependencies { + implementation(project(":libs:encryption:base58")) + implementation(project(":libs:encryption:sha256")) + implementation(project(":libs:encryption:utils")) + implementation(libs.bundles.kotlinx.serialization) + } + } + androidMain { + dependencies { + implementation(libs.protobuf.kotlin.lite) + } + } + commonTest { + dependencies { + implementation(kotlin("test")) + } + } + getByName("androidHostTest") { + dependencies { + implementation(kotlin("test")) + implementation(libs.robolectric) + } + } + } } diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/ByteStringKeys.kt b/libs/encryption/keys/src/androidMain/kotlin/com/getcode/solana/keys/ByteStringKeys.kt similarity index 100% rename from libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/ByteStringKeys.kt rename to libs/encryption/keys/src/androidMain/kotlin/com/getcode/solana/keys/ByteStringKeys.kt diff --git a/libs/encryption/keys/src/androidMain/kotlin/com/getcode/solana/keys/KeyBase64.kt b/libs/encryption/keys/src/androidMain/kotlin/com/getcode/solana/keys/KeyBase64.kt new file mode 100644 index 0000000000..a5c9858317 --- /dev/null +++ b/libs/encryption/keys/src/androidMain/kotlin/com/getcode/solana/keys/KeyBase64.kt @@ -0,0 +1,14 @@ +package com.getcode.solana.keys + +import com.getcode.utils.encodeBase64 + +/** + * `com.getcode.utils.encodeBase64` (`:libs:encryption:utils`) is Android-only, so these two + * extensions can't live in `Key.kt`'s commonMain alongside [KeyType.base58]/[base58Redacted]. They + * have no production call sites today (grep across the repo finds none outside this declaration), + * so androidMain is a mechanical relocation, not a behaviour change: any Android call site that + * used `KeyType.base64()`/`base64Redacted()` before this module became KMP keeps compiling and + * keeps producing the same string. + */ +fun KeyType.base64(): String = bytes.toByteArray().encodeBase64() +fun KeyType.base64Redacted(): String = base64().redact(visibleLength = 8) diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/ByteListSerializer.kt b/libs/encryption/keys/src/androidMain/kotlin/com/getcode/utils/serializer/ByteListSerializer.kt similarity index 100% rename from libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/ByteListSerializer.kt rename to libs/encryption/keys/src/androidMain/kotlin/com/getcode/utils/serializer/ByteListSerializer.kt diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/AccountMeta.kt b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/AccountMeta.kt similarity index 100% rename from libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/AccountMeta.kt rename to libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/AccountMeta.kt diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Key.kt similarity index 93% rename from libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt rename to libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Key.kt index 36de85ac6b..93fbb48e46 100644 --- a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt +++ b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Key.kt @@ -1,6 +1,5 @@ package com.getcode.solana.keys -import com.getcode.utils.encodeBase64 import com.getcode.vendor.Base58 abstract class KeyType(bytes: List) { @@ -29,9 +28,7 @@ abstract class KeyType(bytes: List) { } fun KeyType.base58(): String = Base58.encode(bytes.toByteArray()) -fun KeyType.base64(): String = bytes.toByteArray().encodeBase64() fun KeyType.base58Redacted(): String = base58().redact(visibleLength = 4) -fun KeyType.base64Redacted(): String = base64().redact(visibleLength = 8) fun String.redact(visibleLength: Int = 4): String { if (length <= visibleLength * 2) return this diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/MerkleProof.kt b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/MerkleProof.kt similarity index 100% rename from libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/MerkleProof.kt rename to libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/MerkleProof.kt diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Mint.kt b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Mint.kt similarity index 100% rename from libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Mint.kt rename to libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Mint.kt diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/PublicKey.kt similarity index 100% rename from libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt rename to libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/PublicKey.kt diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Types.kt similarity index 100% rename from libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt rename to libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Types.kt diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/MintSerializer.kt b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/utils/serializer/MintSerializer.kt similarity index 100% rename from libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/MintSerializer.kt rename to libs/encryption/keys/src/commonMain/kotlin/com/getcode/utils/serializer/MintSerializer.kt diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/PublicKeySerializer.kt b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/utils/serializer/PublicKeySerializer.kt similarity index 100% rename from libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/PublicKeySerializer.kt rename to libs/encryption/keys/src/commonMain/kotlin/com/getcode/utils/serializer/PublicKeySerializer.kt From 60ca712f992ae0eb5d8d1040bc07bd09382a814a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 19:06:44 -0400 Subject: [PATCH 07/26] refactor(keys): replace Mint/PublicKey Parcelable with @TypeParceler PublicKey and Mint hand-rolled android.os.Parcelable (writeToParcel, CREATOR) directly in commonMain, which does not compile outside androidMain. Move that to two Parceler objects in a new androidMain KeyParcelers.kt (PublicKeyParceler, MintParceler) and reference them from every holder via kotlinx.parcelize.TypeParceler, so the wire format is unchanged: still a single writeString/readString of the base58 address. TypeParceler only applies at class or property targets, not file, so it is added directly on each concrete data class that carries a Mint or PublicKey field, including classes that inherit their Parcelize codegen from a parent sealed interface (e.g. DeeplinkType.TokenInfo) rather than declaring their own Parcelize. Nine holders needed it, not the ~11 the plan estimated: - apps/flipcash/core: AppRoute (Give, Info, Transactions, Withdrawal), DepositStep.Destination, WithdrawalStep.Amount, DeeplinkType.TokenInfo, WalletDeeplinkConnectionResult.ExternalWalletConnection, TokenPurpose (Swap, ConvertDestination, BuyFunding), TokenSwapPurpose (Buy, Sell, Convert) - services/opencode: MintMetadata (MintMetadata, VmMetadata, LaunchpadMetadata), LocalFiat VerifiedFiatCalculator's VerifiedFiat and SwapId do not carry a Mint/PublicKey field directly (VerifiedFiat only parcels its nested LocalFiat; SwapId's PublicKey is a derived property, not a stored one), so neither needs its own annotation. --- .../kotlin/com/flipcash/app/core/AppRoute.kt | 6 ++++ .../flipcash/app/core/deposit/DepositStep.kt | 3 ++ .../app/core/navigation/DeeplinkType.kt | 6 +++- .../WalletDeeplinkConnectionResult.kt | 3 ++ .../flipcash/app/core/tokens/TokenPurpose.kt | 14 ++++++++-- .../app/core/tokens/TokenSwapPurpose.kt | 14 ++++++++-- .../app/core/withdrawal/WithdrawalStep.kt | 3 ++ libs/encryption/keys/build.gradle.kts | 1 + .../com/getcode/solana/keys/KeyParcelers.kt | 28 +++++++++++++++++++ .../kotlin/com/getcode/solana/keys/Mint.kt | 11 +------- .../com/getcode/solana/keys/PublicKey.kt | 23 ++------------- .../opencode/model/financial/LocalFiat.kt | 3 ++ .../opencode/model/financial/MintMetadata.kt | 6 ++++ 13 files changed, 83 insertions(+), 38 deletions(-) create mode 100644 libs/encryption/keys/src/androidMain/kotlin/com/getcode/solana/keys/KeyParcelers.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 1fae42a78d..43a37a4500 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -27,8 +27,10 @@ import com.getcode.navigation.flow.FlowStep import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.MintParceler import com.getcode.ui.core.RestrictionType import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler import kotlinx.serialization.Serializable import kotlin.reflect.KClass @@ -167,6 +169,7 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data class TokenSelection(val purpose: TokenPurpose) : Sheets @Serializable + @TypeParceler() data class Give(val mint: Mint? = null, val fromTokenInfo: Boolean = false) : Sheets @Serializable @@ -209,6 +212,7 @@ sealed interface AppRoute : NavKey, Parcelable { @Parcelize sealed interface Token : AppRoute { @Serializable + @TypeParceler() data class Info( val mint: Mint, val shortfall: Fiat? = null, @@ -220,6 +224,7 @@ sealed interface AppRoute : NavKey, Parcelable { ) : Token @Serializable + @TypeParceler() data class Transactions(val mint: Mint) : Token @Serializable data class Swap( @@ -288,6 +293,7 @@ sealed interface AppRoute : NavKey, Parcelable { * "Withdraw as USDC" intro, and any other currency lands straight on the amount screen. */ @Serializable + @TypeParceler() data class Withdrawal( val preselectedMint: Mint? = Mint.usdf, ) : Transfers, FlowRouteWithResult { diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/deposit/DepositStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/deposit/DepositStep.kt index 8cb18871f2..90feac87c5 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/deposit/DepositStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/deposit/DepositStep.kt @@ -3,7 +3,9 @@ package com.flipcash.app.core.deposit import android.os.Parcelable import com.getcode.navigation.flow.FlowStep import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.MintParceler import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler import kotlinx.serialization.Serializable /** @@ -21,5 +23,6 @@ sealed interface DepositStep : FlowStep, Parcelable { data object SelectToken: DepositStep @Parcelize @Serializable + @TypeParceler() data class Destination(val mint: Mint) : DepositStep } \ No newline at end of file diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt index e8662f73a2..3cb2e63f0b 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt @@ -6,7 +6,9 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.services.models.chat.ChatId import com.getcode.opencode.model.core.ID import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.MintParceler import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler import kotlinx.serialization.Serializable @Serializable @@ -16,7 +18,9 @@ sealed interface DeeplinkType: Parcelable { @Serializable data class Login(val entropy: String) : DeeplinkType @Serializable data class CashLink(val entropy: String = "") : DeeplinkType - @Serializable data class TokenInfo(val mint: Mint): DeeplinkType, Navigatable + @Serializable + @TypeParceler() + data class TokenInfo(val mint: Mint): DeeplinkType, Navigatable @Serializable data class TipChat(val identifier: ChatIdentifier): DeeplinkType, Navigatable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onramp/deeplinks/WalletDeeplinkConnectionResult.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onramp/deeplinks/WalletDeeplinkConnectionResult.kt index 99d2308c17..18ed9111fc 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onramp/deeplinks/WalletDeeplinkConnectionResult.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onramp/deeplinks/WalletDeeplinkConnectionResult.kt @@ -2,12 +2,15 @@ package com.flipcash.app.core.onramp.deeplinks import android.os.Parcelable import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.PublicKeyParceler import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @Parcelize +@TypeParceler() data class ExternalWalletConnection( @SerialName("public_key") val publicKey: PublicKey, diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt index 4022191533..249b58473e 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt @@ -3,7 +3,9 @@ package com.flipcash.app.core.tokens import android.os.Parcelable import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.MintParceler import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler import kotlinx.serialization.Serializable @Serializable @@ -16,20 +18,26 @@ sealed interface TokenPurpose: Parcelable { @Serializable data object Select : TriggersChange { } - @Serializable data class Swap(val desiredToken: Mint, val amount: Fiat) : TokenPurpose + @Serializable + @TypeParceler() + data class Swap(val desiredToken: Mint, val amount: Fiat) : TokenPurpose /** * Picks the currency a Convert lands in. [source] is the currency being spent (excluded from * the list); [current] is the destination already chosen, shown with a checkmark. */ - @Serializable data class ConvertDestination(val source: Mint, val current: Mint) : TokenPurpose + @Serializable + @TypeParceler() + data class ConvertDestination(val source: Mint, val current: Mint) : TokenPurpose /** * Picks the currency a Get is funded from. [target] is the currency being bought (excluded from * the list, since the server rejects same-mint swaps); [current] is the source already chosen, * shown with a checkmark. */ - @Serializable data class BuyFunding(val target: Mint, val current: Mint) : TokenPurpose + @Serializable + @TypeParceler() + data class BuyFunding(val target: Mint, val current: Mint) : TokenPurpose @Serializable data class LaunchFunding(val amount: Fiat): TokenPurpose @Serializable data class Tip(val amount: Fiat?): TriggersChange @Serializable data object Withdraw: TokenPurpose diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt index d45cae9721..70325a2fcf 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt @@ -2,7 +2,9 @@ package com.flipcash.app.core.tokens import android.os.Parcelable import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.MintParceler import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler import kotlinx.serialization.Serializable /** @@ -27,18 +29,24 @@ sealed interface SwapPurpose : Parcelable { val mint: Mint sealed interface BalanceIncrease sealed interface BalanceDecrease - @Serializable data class Buy( + @Serializable + @TypeParceler() + data class Buy( override val mint: Mint, val fundingSource: FundingSource = FundingSource.Flexible, ) : SwapPurpose, BalanceIncrease - @Serializable data class Sell(override val mint: Mint) : SwapPurpose, BalanceDecrease + @Serializable + @TypeParceler() + data class Sell(override val mint: Mint) : SwapPurpose, BalanceDecrease /** * Converts one held currency directly into another. [mint] is the *source* currency the amount * is entered in (so balance/limit semantics match [Sell]); [destinationMint] is what the user * receives. */ - @Serializable data class Convert( + @Serializable + @TypeParceler() + data class Convert( override val mint: Mint, val destinationMint: Mint, ) : SwapPurpose, BalanceDecrease diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/withdrawal/WithdrawalStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/withdrawal/WithdrawalStep.kt index 2500725704..b5ead0de7a 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/withdrawal/WithdrawalStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/withdrawal/WithdrawalStep.kt @@ -4,7 +4,9 @@ import android.os.Parcelable import com.getcode.navigation.flow.FlowStep import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.MintParceler import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler import kotlinx.serialization.Serializable /** @@ -22,6 +24,7 @@ sealed interface WithdrawalStep : FlowStep, Parcelable { data object SelectToken: WithdrawalStep @Parcelize @Serializable + @TypeParceler() data class Amount(val mint: Mint) : WithdrawalStep @Parcelize diff --git a/libs/encryption/keys/build.gradle.kts b/libs/encryption/keys/build.gradle.kts index 95a27e5c9a..9c8a2e1b1c 100644 --- a/libs/encryption/keys/build.gradle.kts +++ b/libs/encryption/keys/build.gradle.kts @@ -1,6 +1,7 @@ plugins { kotlin("multiplatform") id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.serialization) } diff --git a/libs/encryption/keys/src/androidMain/kotlin/com/getcode/solana/keys/KeyParcelers.kt b/libs/encryption/keys/src/androidMain/kotlin/com/getcode/solana/keys/KeyParcelers.kt new file mode 100644 index 0000000000..1a7e47c5da --- /dev/null +++ b/libs/encryption/keys/src/androidMain/kotlin/com/getcode/solana/keys/KeyParcelers.kt @@ -0,0 +1,28 @@ +package com.getcode.solana.keys + +import android.os.Parcel +import com.getcode.vendor.Base58 +import kotlinx.parcelize.Parceler + +/** + * [PublicKey] and [Mint] used to implement [android.os.Parcelable] directly, writing and reading + * the base58 string (see the old `writeToParcel`/`Parcel` constructor). An `androidMain` source + * set can't add a supertype to a `commonMain` class, so that Parcelable conformance moves here as + * a [Parceler], applied at holder sites via `@TypeParceler`. The parcel format is unchanged: still + * exactly one string, base58-encoded. + */ +object PublicKeyParceler : Parceler { + override fun create(parcel: Parcel): PublicKey = PublicKey(parcel.readString().orEmpty()) + + override fun PublicKey.write(parcel: Parcel, flags: Int) { + parcel.writeString(Base58.encode(bytes.toByteArray())) + } +} + +object MintParceler : Parceler { + override fun create(parcel: Parcel): Mint = Mint(parcel.readString().orEmpty()) + + override fun Mint.write(parcel: Parcel, flags: Int) { + parcel.writeString(Base58.encode(bytes.toByteArray())) + } +} diff --git a/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Mint.kt b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Mint.kt index 5a4ee3532a..e1fbd46a10 100644 --- a/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Mint.kt +++ b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Mint.kt @@ -1,7 +1,5 @@ package com.getcode.solana.keys -import android.os.Parcel -import android.os.Parcelable import com.getcode.utils.serializer.MintAsStringSerializer import com.getcode.vendor.Base58 import kotlinx.serialization.Serializable @@ -11,13 +9,6 @@ class Mint(bytes: List): PublicKey(bytes) { constructor(base58: String) : this(Base58.decode(base58).toList()) companion object { - @JvmField - val CREATOR: Parcelable.Creator = - object : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel) = Mint(parcel.readString().orEmpty()) - override fun newArray(size: Int) = arrayOfNulls(size) - } - val usdc: Mint get() = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") @@ -25,4 +16,4 @@ class Mint(bytes: List): PublicKey(bytes) { get() = Mint("5AMAA9JV9H97YYVxx8F6FsCMmTwXSuTTQneiup4RYAUQ") } -} \ No newline at end of file +} diff --git a/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/PublicKey.kt b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/PublicKey.kt index 1664159de9..e72f35446b 100644 --- a/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/PublicKey.kt +++ b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/PublicKey.kt @@ -1,18 +1,14 @@ package com.getcode.solana.keys -import android.os.Parcel -import android.os.Parcelable import com.getcode.utils.serializer.PublicKeyAsStringSerializer import com.getcode.vendor.Base58 import kotlinx.serialization.Serializable @Serializable(with = PublicKeyAsStringSerializer::class) -open class PublicKey(bytes: List) : Key32(bytes), Parcelable { +open class PublicKey(bytes: List) : Key32(bytes) { constructor(base58: String): this(Base58.decode(base58).toList()) - constructor(parcel: Parcel): this(parcel.readString().orEmpty()) - val description: String = base58() companion object { @@ -24,13 +20,6 @@ open class PublicKey(bytes: List) : Key32(bytes), Parcelable { } val ZERO: PublicKey = PublicKey(zero.bytes) - - @JvmField - val CREATOR: Parcelable.Creator = - object : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel) = PublicKey(parcel) - override fun newArray(size: Int) = arrayOfNulls(size) - } } override fun equals(other: Any?): Boolean { @@ -49,12 +38,4 @@ open class PublicKey(bytes: List) : Key32(bytes), Parcelable { return base58() } - override fun describeContents(): Int { - return 0 - } - - override fun writeToParcel(dest: Parcel, flags: Int) { - dest.writeString(Base58.encode(bytes.toByteArray())) - } - -} \ No newline at end of file +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt index abb86f2be4..86b385c34d 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt @@ -4,7 +4,9 @@ import android.os.Parcelable import com.getcode.opencode.internal.extensions.fractionDigits import com.getcode.opencode.model.transactions.ExchangeData import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.MintParceler import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler import kotlinx.serialization.Serializable import javax.annotation.concurrent.Immutable @@ -34,6 +36,7 @@ import javax.annotation.concurrent.Immutable @Serializable @Parcelize @Immutable +@TypeParceler() data class LocalFiat( val underlyingTokenAmount: Fiat, val nativeAmount: Fiat, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt index 6e9cb183e4..e8da8811ec 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt @@ -9,9 +9,12 @@ import com.getcode.opencode.internal.solana.vmAuthority import com.getcode.opencode.model.ui.TokenBillCustomizations import com.getcode.opencode.solana.keys.TimelockDerivedAccounts import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.MintParceler import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.PublicKeyParceler import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler import java.math.BigDecimal import java.math.RoundingMode import java.text.DecimalFormat @@ -156,6 +159,7 @@ fun MintMetadata.Companion.fromLaunch( * @property billCustomizations Optional visual customizations for the bill for this token when give/grabbed */ @Parcelize +@TypeParceler() data class MintMetadata( val address: Mint, val decimals: Int, @@ -214,6 +218,7 @@ fun Token.formattedQuantity(quarks: Long): String { * to 21 days */ @Parcelize +@TypeParceler() data class VmMetadata( val vm: PublicKey, val authority: PublicKey, @@ -236,6 +241,7 @@ data class VmMetadata( * @property sellFeeBps Precent fee for sells in basis points, currently hardcoded to 1% */ @Parcelize +@TypeParceler() data class LaunchpadMetadata( val currencyConfig: PublicKey, val liquidityPool: PublicKey, From 96e34d64784bc0626a4253b84027add1d8fa7001 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 19:07:01 -0400 Subject: [PATCH 08/26] refactor(keys): move keys tests to commonTest/androidHostTest KeyTest, MintTest, PublicKeyTest, and AccountMetaTest only use kotlin.test assertions, so they move to commonTest and now run against every KMP target. SerializerTest stays JVM-only in androidHostTest since it drives Robolectric (RobolectricTestRunner, Config.NONE) to exercise the ByteListAsBase64Serializer/PublicKeyAsStringSerializer JSON round trip. --- .../kotlin/com/getcode/utils/serializer/SerializerTest.kt | 0 .../kotlin/com/getcode/solana/keys/AccountMetaTest.kt | 0 .../kotlin/com/getcode/solana/keys/KeyTest.kt | 0 .../kotlin/com/getcode/solana/keys/MintTest.kt | 0 .../kotlin/com/getcode/solana/keys/PublicKeyTest.kt | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename libs/encryption/keys/src/{test => androidHostTest}/kotlin/com/getcode/utils/serializer/SerializerTest.kt (100%) rename libs/encryption/keys/src/{test => commonTest}/kotlin/com/getcode/solana/keys/AccountMetaTest.kt (100%) rename libs/encryption/keys/src/{test => commonTest}/kotlin/com/getcode/solana/keys/KeyTest.kt (100%) rename libs/encryption/keys/src/{test => commonTest}/kotlin/com/getcode/solana/keys/MintTest.kt (100%) rename libs/encryption/keys/src/{test => commonTest}/kotlin/com/getcode/solana/keys/PublicKeyTest.kt (100%) diff --git a/libs/encryption/keys/src/test/kotlin/com/getcode/utils/serializer/SerializerTest.kt b/libs/encryption/keys/src/androidHostTest/kotlin/com/getcode/utils/serializer/SerializerTest.kt similarity index 100% rename from libs/encryption/keys/src/test/kotlin/com/getcode/utils/serializer/SerializerTest.kt rename to libs/encryption/keys/src/androidHostTest/kotlin/com/getcode/utils/serializer/SerializerTest.kt diff --git a/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/AccountMetaTest.kt b/libs/encryption/keys/src/commonTest/kotlin/com/getcode/solana/keys/AccountMetaTest.kt similarity index 100% rename from libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/AccountMetaTest.kt rename to libs/encryption/keys/src/commonTest/kotlin/com/getcode/solana/keys/AccountMetaTest.kt diff --git a/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/KeyTest.kt b/libs/encryption/keys/src/commonTest/kotlin/com/getcode/solana/keys/KeyTest.kt similarity index 100% rename from libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/KeyTest.kt rename to libs/encryption/keys/src/commonTest/kotlin/com/getcode/solana/keys/KeyTest.kt diff --git a/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/MintTest.kt b/libs/encryption/keys/src/commonTest/kotlin/com/getcode/solana/keys/MintTest.kt similarity index 100% rename from libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/MintTest.kt rename to libs/encryption/keys/src/commonTest/kotlin/com/getcode/solana/keys/MintTest.kt diff --git a/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/PublicKeyTest.kt b/libs/encryption/keys/src/commonTest/kotlin/com/getcode/solana/keys/PublicKeyTest.kt similarity index 100% rename from libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/PublicKeyTest.kt rename to libs/encryption/keys/src/commonTest/kotlin/com/getcode/solana/keys/PublicKeyTest.kt From 2224488c70785703e721e9f3c683a2cf69f3556d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 19:23:29 -0400 Subject: [PATCH 09/26] build(solana): scaffold :libs:solana:encoding KMP module Add the module (Android + iosArm64/iosSimulatorArm64/iosX64/macosArm64/ macosX64, matching :libs:currency-math:discrete-curve) and register it in settings.gradle.kts, including kmpUnitTestModules so its aggregate test task picks it up. --- libs/solana/encoding/build.gradle.kts | 39 +++++++++++++++++++++++++++ settings.gradle.kts | 2 ++ 2 files changed, 41 insertions(+) create mode 100644 libs/solana/encoding/build.gradle.kts diff --git a/libs/solana/encoding/build.gradle.kts b/libs/solana/encoding/build.gradle.kts new file mode 100644 index 0000000000..e4e9090ae5 --- /dev/null +++ b/libs/solana/encoding/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") +} + +kotlin { + android { + namespace = "${Gradle.codeNamespace}.opencode.solana.encoding" + compileSdk { + version = release(libs.versions.android.compileSdk.get().toInt()) { + minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() + } + } + minSdk = 29 + withHostTest {} + } + + iosArm64() + iosSimulatorArm64() + iosX64() + macosArm64() + macosX64() + + sourceSets { + commonMain { + dependencies { + implementation(project(":libs:encryption:keys")) + implementation(project(":libs:encryption:utils")) + implementation(project(":libs:encryption:ed25519")) + implementation(project(":libs:encryption:sha256")) + } + } + commonTest { + dependencies { + implementation(kotlin("test")) + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1245b96476..c8e313e2b0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -209,6 +209,7 @@ include( ":libs:quickresponse", ":libs:search", + ":libs:solana:encoding", ":libs:vibrator:bindings", ":libs:vibrator:impl", @@ -295,6 +296,7 @@ val kmpUnitTestModules = setOf( // (macOS .dylib / Linux .so); it can't load on the Linux CI runner. Its ed25519.json // parity is gated via the iOS cinterop path (macOS) instead — so it's excluded here. ":libs:encryption:utils", + ":libs:solana:encoding", ) // ed25519 and mnemonic excluded: both pull in the JNI-backed Ed25519Kmp Android actual for their // host vector tests, which can't load on the Linux CI runner (see kmpUnitTestModules). From f4df60de877bcadd8c758a70dceed9fd17b7edab Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 19:23:47 -0400 Subject: [PATCH 10/26] refactor(solana): move encoding sources into :libs:solana:encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Instruction, Message, MessageHeader, LegacyMessage, VersionedMessage, SolanaTransaction, ShortVec, MessageAddressLookupTable, and AddressLookupTable out of :services:opencode and into the new module's commonMain, keeping their packages. ShortVec stays internal, so ShortVecTest.kt moves with it — internal visibility is module-scoped and the test can no longer compile against :services:opencode once the type lives elsewhere. Extract SolanaTransaction.diff() into SolanaTransactionDiff.kt, staying in :services:opencode: it depends on Differ.kt's printDiff/printMatch, which use timber.log.Timber and can't move. Its MessageHeader.description reference is inlined rather than exposed, since that property is internal inside the new module and diff() was its only caller. Replace the two javaClass-based equals() checks (Instruction, MessageHeader) with `other !is X`, matching the KMP-safe pattern already used elsewhere (Key32, PublicKey) — javaClass doesn't resolve on Kotlin/Native. Neither class has subclasses, so this preserves current equals() semantics exactly. --- .../opencode/internal/solana/ShortVec.kt | 0 .../solana/model/MessageAddressLookupTable.kt | 0 .../model/transactions/AddressLookupTable.kt | 0 .../getcode/opencode/solana/Instruction.kt | 2 +- .../getcode/opencode/solana/LegacyMessage.kt | 0 .../com/getcode/opencode/solana/Message.kt | 0 .../getcode/opencode/solana/MessageHeader.kt | 2 +- .../opencode/solana/SolanaTransaction.kt | 67 ---------------- .../opencode/solana/VersionedMessage.kt | 0 .../opencode/internal/solana/ShortVecTest.kt | 0 .../solana/utils/SolanaTransactionDiff.kt | 80 +++++++++++++++++++ 11 files changed, 82 insertions(+), 69 deletions(-) rename {services/opencode/src/main => libs/solana/encoding/src/commonMain}/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt (100%) rename {services/opencode/src/main => libs/solana/encoding/src/commonMain}/kotlin/com/getcode/opencode/internal/solana/model/MessageAddressLookupTable.kt (100%) rename {services/opencode/src/main => libs/solana/encoding/src/commonMain}/kotlin/com/getcode/opencode/model/transactions/AddressLookupTable.kt (100%) rename {services/opencode/src/main => libs/solana/encoding/src/commonMain}/kotlin/com/getcode/opencode/solana/Instruction.kt (98%) rename {services/opencode/src/main => libs/solana/encoding/src/commonMain}/kotlin/com/getcode/opencode/solana/LegacyMessage.kt (100%) rename {services/opencode/src/main => libs/solana/encoding/src/commonMain}/kotlin/com/getcode/opencode/solana/Message.kt (100%) rename {services/opencode/src/main => libs/solana/encoding/src/commonMain}/kotlin/com/getcode/opencode/solana/MessageHeader.kt (96%) rename {services/opencode/src/main => libs/solana/encoding/src/commonMain}/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt (87%) rename {services/opencode/src/main => libs/solana/encoding/src/commonMain}/kotlin/com/getcode/opencode/solana/VersionedMessage.kt (100%) rename {services/opencode/src/test => libs/solana/encoding/src/commonTest}/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt (100%) create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/utils/SolanaTransactionDiff.kt diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt similarity index 100% rename from services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt rename to libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/model/MessageAddressLookupTable.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/internal/solana/model/MessageAddressLookupTable.kt similarity index 100% rename from services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/model/MessageAddressLookupTable.kt rename to libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/internal/solana/model/MessageAddressLookupTable.kt diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/AddressLookupTable.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/model/transactions/AddressLookupTable.kt similarity index 100% rename from services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/AddressLookupTable.kt rename to libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/model/transactions/AddressLookupTable.kt diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/Instruction.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt similarity index 98% rename from services/opencode/src/main/kotlin/com/getcode/opencode/solana/Instruction.kt rename to libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt index 8eeaf1ecbe..5a97ad3c7e 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/Instruction.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt @@ -29,7 +29,7 @@ data class Instruction( override fun equals(other: Any?): Boolean { if (this === other) return true - if (javaClass != other?.javaClass) return false + if (other !is Instruction) return false other as Instruction diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/LegacyMessage.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt similarity index 100% rename from services/opencode/src/main/kotlin/com/getcode/opencode/solana/LegacyMessage.kt rename to libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/Message.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Message.kt similarity index 100% rename from services/opencode/src/main/kotlin/com/getcode/opencode/solana/Message.kt rename to libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Message.kt diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/MessageHeader.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/MessageHeader.kt similarity index 96% rename from services/opencode/src/main/kotlin/com/getcode/opencode/solana/MessageHeader.kt rename to libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/MessageHeader.kt index 1633bf7665..1cc977611d 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/MessageHeader.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/MessageHeader.kt @@ -18,7 +18,7 @@ open class MessageHeader( override fun equals(other: Any?): Boolean { if (this === other) return true - if (javaClass != other?.javaClass) return false + if (other !is MessageHeader) return false other as MessageHeader diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt similarity index 87% rename from services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt rename to libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt index c2855c17d3..d55c75bb69 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt @@ -5,8 +5,6 @@ import com.getcode.opencode.internal.solana.ShortVec import com.getcode.opencode.internal.solana.model.MessageAddressLookupTable import com.getcode.utils.DataSlice.chunk import com.getcode.utils.DataSlice.tail -import com.getcode.opencode.internal.solana.utils.printDiff -import com.getcode.opencode.internal.solana.utils.printMatch import com.getcode.opencode.model.transactions.AddressLookupTable import com.getcode.solana.keys.AccountMeta import com.getcode.solana.keys.Hash @@ -338,68 +336,3 @@ data class SolanaTransaction(val message: Message, val signatures: List Date: Fri, 11 Sep 2026 19:23:54 -0400 Subject: [PATCH 11/26] build(opencode): depend on :libs:solana:encoding Add it as api so transitive consumers of :services:opencode (PhantomWalletController et al.) keep resolving Instruction, Message, SolanaTransaction, and friends without adding their own dependency now that those types live in a separate module. --- services/opencode/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/services/opencode/build.gradle.kts b/services/opencode/build.gradle.kts index a7f5334f2d..9cdda6b039 100644 --- a/services/opencode/build.gradle.kts +++ b/services/opencode/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { api(project(":libs:encryption:sha512")) api(project(":libs:encryption:utils")) api(project(":libs:logging")) + api(project(":libs:solana:encoding")) api(project(":libs:locale:bindings")) implementation(project(":libs:locale:impl")) api(project(":libs:network:connectivity:bindings")) From a93e1bd66300b586e8f2a734a086e36d08d636a3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 19:26:49 -0400 Subject: [PATCH 12/26] fix(solana): repoint diff() import after the SolanaTransactionDiff move IntentExecutor and StatefulSwapExecutor call SolanaTransaction.diff() to log what differs when the server rejects a transaction for an invalid signature - a real call site, not the dead code the extraction assumed. Update both imports to the function's new package and correct the doc comment that said otherwise. --- .../opencode/internal/network/executors/IntentExecutor.kt | 2 +- .../internal/network/executors/StatefulSwapExecutor.kt | 2 +- .../opencode/internal/solana/utils/SolanaTransactionDiff.kt | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/IntentExecutor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/IntentExecutor.kt index 6d9f07fbb6..cca7f95e1b 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/IntentExecutor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/IntentExecutor.kt @@ -8,8 +8,8 @@ import com.getcode.opencode.internal.bidi.BidirectionalStreamReference import com.getcode.opencode.internal.bidi.openBidirectionalStreamForResult import com.getcode.opencode.internal.network.api.TransactionApi import com.getcode.opencode.model.core.errors.SubmitIntentError +import com.getcode.opencode.internal.solana.utils.diff import com.getcode.opencode.solana.SolanaTransaction -import com.getcode.opencode.solana.diff import com.getcode.opencode.solana.intents.IntentType import com.getcode.opencode.solana.intents.ServerParameter import com.getcode.services.opencode.BuildConfig diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt index d19c9be287..0758086a25 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt @@ -13,9 +13,9 @@ import com.getcode.opencode.model.transactions.StatefulSwapRequest import com.getcode.opencode.model.transactions.SwapResult import com.getcode.opencode.model.transactions.SwapProgram import com.getcode.opencode.model.transactions.VerifiedSwapMetadata +import com.getcode.opencode.internal.solana.utils.diff import com.getcode.opencode.solana.SolanaTransaction import com.getcode.opencode.solana.fromBytes -import com.getcode.opencode.solana.diff import com.getcode.services.opencode.BuildConfig import com.getcode.solana.keys.Signature import com.getcode.solana.keys.base58 diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/utils/SolanaTransactionDiff.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/utils/SolanaTransactionDiff.kt index d21776f257..60aecdf2bd 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/utils/SolanaTransactionDiff.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/utils/SolanaTransactionDiff.kt @@ -7,7 +7,8 @@ import com.getcode.solana.keys.base58 * Diagnostic diff between two [SolanaTransaction]s, printed via [printDiff]/[printMatch]. Kept in * `:services:opencode` rather than moving with [SolanaTransaction] into `:libs:solana:encoding` * because `printDiff`/`printMatch` depend on `timber.log.Timber`, which is Android-only. Called - * from nowhere in the codebase today; kept rather than deleted per standing decision. + * from `IntentExecutor` and `StatefulSwapExecutor` to log what differs between the transaction the + * client built and the one the server reports back on an invalid-signature error. * * The header line is formatted inline here (matching `MessageHeader.description`'s * `"H{...}"` shape) rather than calling that extension: it is `internal` inside From 478af9b4e7280c2b378f38ccfc690daba9375fd3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 19:34:50 -0400 Subject: [PATCH 13/26] refactor(solana): move transaction signing out of :libs:solana:encoding SolanaTransaction.sign/signatures depended on com.getcode.ed25519.Ed25519, the Android-only JNI wrapper (its KeyPair is Parcelable), not the KMP Ed25519Kmp. That import kept :libs:solana:encoding from compiling on the Apple targets, since the module is meant to be encoding only. Move both functions to services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransactionSigning.kt as extensions on SolanaTransaction, unchanged, alongside SigningError. Ed25519 stays the legacy JNI class; nothing migrates to Ed25519Kmp here. --- .../opencode/solana/SolanaTransaction.kt | 37 --------------- .../network/api/intents/IntentStatefulSwap.kt | 1 + .../api/intents/IntentStatelessSwap.kt | 1 + .../solana/SolanaTransactionSigning.kt | 45 +++++++++++++++++++ 4 files changed, 47 insertions(+), 37 deletions(-) create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransactionSigning.kt diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt index d55c75bb69..003d71a19b 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt @@ -1,6 +1,5 @@ package com.getcode.opencode.solana -import com.getcode.ed25519.Ed25519 import com.getcode.opencode.internal.solana.ShortVec import com.getcode.opencode.internal.solana.model.MessageAddressLookupTable import com.getcode.utils.DataSlice.chunk @@ -59,36 +58,6 @@ data class SolanaTransaction(val message: Message, val signatures: List { - return keyPair.map { kp -> - val result = kp.sign(message.encode().toByteArray()).toList() - Signature(result) - } - } - - fun sign(vararg keyPairs: Ed25519.KeyPair): List { - val requiredSignatureCount = message.header.requiredSignatures - if (keyPairs.size > requiredSignatureCount) { - throw Exception(SigningError.tooManySigners.name) - } - - val messageData = message.encode() - val newSignatures = mutableListOf() - - keyPairs.forEach { keyPair -> - val signatureIndex = - message.accountKeys.indexOfFirst { it.bytes == keyPair.publicKeyBytes.toList() } - if (signatureIndex == -1) { - throw Exception("accountNotInAccountList. Account: ${keyPair.publicKey}") - } - - val signature = Ed25519.sign(messageData.toByteArray(), keyPair) - newSignatures.add(Signature(signature.toList())) - } - - return newSignatures - } - fun encode(): List { val data = mutableListOf() data.addAll(ShortVec.encodeList(signatures.map { it.bytes })) @@ -105,12 +74,6 @@ data class SolanaTransaction(val message: Message, val signatures: List): SolanaTransaction? { val (signatureCount, payload) = ShortVec.decodeLen(list) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatefulSwap.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatefulSwap.kt index b5cd0ae07f..27ff2033fa 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatefulSwap.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatefulSwap.kt @@ -16,6 +16,7 @@ import com.getcode.opencode.model.transactions.SwapProgram import com.getcode.opencode.model.transactions.VerifiedSwapMetadata import com.getcode.opencode.solana.SolanaTransaction import com.getcode.opencode.solana.TransactionBuilder +import com.getcode.opencode.solana.signatures import com.getcode.solana.keys.Signature internal class IntentStatefulSwap( diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatelessSwap.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatelessSwap.kt index f4219e2fd2..30f249abe2 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatelessSwap.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatelessSwap.kt @@ -11,6 +11,7 @@ import com.getcode.opencode.model.transactions.StatelessSwapRequest import com.getcode.opencode.model.transactions.StatelessSwapServerParameters import com.getcode.opencode.solana.SolanaTransaction import com.getcode.opencode.solana.TransactionBuilder +import com.getcode.opencode.solana.signatures import com.getcode.solana.keys.Signature internal class IntentStatelessSwap( diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransactionSigning.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransactionSigning.kt new file mode 100644 index 0000000000..7cbe2e0ae9 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransactionSigning.kt @@ -0,0 +1,45 @@ +package com.getcode.opencode.solana + +import com.getcode.ed25519.Ed25519 +import com.getcode.solana.keys.Signature + +/** + * Signing depends on [Ed25519], the Android-only JNI wrapper (its `KeyPair` is `Parcelable`), not + * the KMP `Ed25519Kmp`. Kept out of [SolanaTransaction] itself so `:libs:solana:encoding` compiles + * on the Apple targets; encoding a transaction doesn't need to sign one. + */ +fun SolanaTransaction.signatures(vararg keyPair: Ed25519.KeyPair): List { + return keyPair.map { kp -> + val result = kp.sign(message.encode().toByteArray()).toList() + Signature(result) + } +} + +fun SolanaTransaction.sign(vararg keyPairs: Ed25519.KeyPair): List { + val requiredSignatureCount = message.header.requiredSignatures + if (keyPairs.size > requiredSignatureCount) { + throw Exception(SigningError.tooManySigners.name) + } + + val messageData = message.encode() + val newSignatures = mutableListOf() + + keyPairs.forEach { keyPair -> + val signatureIndex = + message.accountKeys.indexOfFirst { it.bytes == keyPair.publicKeyBytes.toList() } + if (signatureIndex == -1) { + throw Exception("accountNotInAccountList. Account: ${keyPair.publicKey}") + } + + val signature = Ed25519.sign(messageData.toByteArray(), keyPair) + newSignatures.add(Signature(signature.toList())) + } + + return newSignatures +} + +enum class SigningError { + tooManySigners, + accountNotInAccountList, + invalidKey +} From faccb90d9bc958c889d986cf056b3ea16942fc4d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 19:47:20 -0400 Subject: [PATCH 14/26] test(solana): move the message vector suites to commonTest Move the Solana legacy-message and compact-message vector tests (plus VersionedMessageV0Test, SolanaTransactionLookupTableTest, and InstructionIntegrationTest) from services/opencode/src/test into libs/solana/encoding/src/commonTest, alongside the encoding code they exercise. Neither moved test calls the signing extension that still lives in :services:opencode, so nothing needs to stay behind. Wire the flipcash.kmp.test.fixtures plugin so the two vector JSON files compile into a generated TestFixtures.kt readTestResource(), since Kotlin/Native test binaries carry no resource bundle. Replace the JVM-only bits the moved tests relied on (javaClass.getResourceAsStream, String.format("%02x"), toByteArray(Charsets.UTF_8), java.security.MessageDigest) with portable equivalents (readTestResource, hexEncodedString, encodeToByteArray, Sha256Hash) without changing what either test asserts. Update test-vectors/README.md's run matrix and sync commands to point at the new location and note both suites now also run on iosSimulatorArm64Test/macosArm64Test, not just the JVM host. --- libs/solana/encoding/build.gradle.kts | 8 ++++++++ .../opencode/solana/CompactMessageVectorTest.kt | 15 +++++++++------ .../opencode/solana/InstructionIntegrationTest.kt | 0 .../opencode/solana/SolanaMessageVectorTest.kt | 9 ++++++--- .../solana/SolanaTransactionLookupTableTest.kt | 0 .../opencode/solana/VersionedMessageV0Test.kt | 0 .../commonTest}/resources/compact_message.json | 0 .../src/commonTest}/resources/solana_message.json | 0 test-vectors/README.md | 13 +++++++------ 9 files changed, 30 insertions(+), 15 deletions(-) rename {services/opencode/src/test => libs/solana/encoding/src/commonTest}/kotlin/com/getcode/opencode/solana/CompactMessageVectorTest.kt (79%) rename {services/opencode/src/test => libs/solana/encoding/src/commonTest}/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt (100%) rename {services/opencode/src/test => libs/solana/encoding/src/commonTest}/kotlin/com/getcode/opencode/solana/SolanaMessageVectorTest.kt (87%) rename {services/opencode/src/test => libs/solana/encoding/src/commonTest}/kotlin/com/getcode/opencode/solana/SolanaTransactionLookupTableTest.kt (100%) rename {services/opencode/src/test => libs/solana/encoding/src/commonTest}/kotlin/com/getcode/opencode/solana/VersionedMessageV0Test.kt (100%) rename {services/opencode/src/test => libs/solana/encoding/src/commonTest}/resources/compact_message.json (100%) rename {services/opencode/src/test => libs/solana/encoding/src/commonTest}/resources/solana_message.json (100%) diff --git a/libs/solana/encoding/build.gradle.kts b/libs/solana/encoding/build.gradle.kts index e4e9090ae5..d660217b55 100644 --- a/libs/solana/encoding/build.gradle.kts +++ b/libs/solana/encoding/build.gradle.kts @@ -1,6 +1,13 @@ plugins { kotlin("multiplatform") id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.test.fixtures) +} + +// Compiles `src/commonTest/resources` into a generated `TestFixtures.kt` on `commonTest` -- +// see the `flipcash.kmp.test.fixtures` convention plugin. +testFixtures { + packageName = "com.getcode.opencode.solana" } kotlin { @@ -33,6 +40,7 @@ kotlin { commonTest { dependencies { implementation(kotlin("test")) + implementation(libs.kotlinx.serialization.json) } } } diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/CompactMessageVectorTest.kt b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/CompactMessageVectorTest.kt similarity index 79% rename from services/opencode/src/test/kotlin/com/getcode/opencode/solana/CompactMessageVectorTest.kt rename to libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/CompactMessageVectorTest.kt index cde542e994..5d14337f54 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/CompactMessageVectorTest.kt +++ b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/CompactMessageVectorTest.kt @@ -1,12 +1,13 @@ package com.getcode.opencode.solana +import com.getcode.crypt.Sha256Hash import com.getcode.solana.keys.PublicKey +import com.getcode.utils.hexEncodedString 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 @@ -20,7 +21,9 @@ import kotlin.test.assertTrue * 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/`. + * Runs on every target this module targets (JVM host + Kotlin/Native). Fixture compiled in from + * `src/commonTest/resources/compact_message.json` (synced from `code/test-vectors/`) by the + * `flipcash.kmp.test.fixtures` convention plugin — see `readTestResource`. */ class CompactMessageVectorTest { @@ -33,7 +36,7 @@ class CompactMessageVectorTest { @Test fun compact_message_matches_canonical_vectors() { - val text = javaClass.getResourceAsStream("/compact_message.json")!!.bufferedReader().use { it.readText() } + val text = readTestResource("compact_message.json") val vectors = Json.parseToJsonElement(text).jsonObject["vectors"]!!.jsonArray assertTrue(vectors.isNotEmpty(), "no vectors loaded") @@ -41,7 +44,7 @@ class CompactMessageVectorTest { 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(v["domain"]!!.jsonPrimitive.content.encodeToByteArray().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)) } @@ -50,10 +53,10 @@ class CompactMessageVectorTest { val bytes = msg.toByteArray() assertEquals(v["message"]!!.jsonPrimitive.content, bytes.toHex(), "message bytes mismatch for $name") - val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + val digest = Sha256Hash.hash(bytes) assertEquals(v["sha256"]!!.jsonPrimitive.content, digest.toHex(), "sha256 mismatch for $name") } } } -private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } +private fun ByteArray.toHex(): String = toList().hexEncodedString() diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt similarity index 100% rename from services/opencode/src/test/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt rename to libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaMessageVectorTest.kt b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/SolanaMessageVectorTest.kt similarity index 87% rename from services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaMessageVectorTest.kt rename to libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/SolanaMessageVectorTest.kt index 1442be00b6..4817cc4a01 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaMessageVectorTest.kt +++ b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/SolanaMessageVectorTest.kt @@ -3,6 +3,7 @@ package com.getcode.opencode.solana import com.getcode.solana.keys.AccountMeta import com.getcode.solana.keys.Hash import com.getcode.solana.keys.PublicKey +import com.getcode.utils.hexEncodedString import kotlinx.serialization.json.Json import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonArray @@ -18,7 +19,9 @@ import kotlin.test.assertTrue * 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/`. + * Runs on every target this module targets (JVM host + Kotlin/Native). Fixture compiled in from + * `src/commonTest/resources/solana_message.json` (synced from `code/test-vectors/`) by the + * `flipcash.kmp.test.fixtures` convention plugin — see `readTestResource`. */ class SolanaMessageVectorTest { @@ -35,7 +38,7 @@ class SolanaMessageVectorTest { @Test fun message_matches_canonical_vectors() { - val text = javaClass.getResourceAsStream("/solana_message.json")!!.bufferedReader().use { it.readText() } + val text = readTestResource("solana_message.json") val vectors = Json.parseToJsonElement(text).jsonObject["vectors"]!!.jsonArray assertTrue(vectors.isNotEmpty(), "no vectors loaded") @@ -64,4 +67,4 @@ class SolanaMessageVectorTest { 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) } +private fun ByteArray.toHex(): String = toList().hexEncodedString() diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaTransactionLookupTableTest.kt b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/SolanaTransactionLookupTableTest.kt similarity index 100% rename from services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaTransactionLookupTableTest.kt rename to libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/SolanaTransactionLookupTableTest.kt diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/VersionedMessageV0Test.kt b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/VersionedMessageV0Test.kt similarity index 100% rename from services/opencode/src/test/kotlin/com/getcode/opencode/solana/VersionedMessageV0Test.kt rename to libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/VersionedMessageV0Test.kt diff --git a/services/opencode/src/test/resources/compact_message.json b/libs/solana/encoding/src/commonTest/resources/compact_message.json similarity index 100% rename from services/opencode/src/test/resources/compact_message.json rename to libs/solana/encoding/src/commonTest/resources/compact_message.json diff --git a/services/opencode/src/test/resources/solana_message.json b/libs/solana/encoding/src/commonTest/resources/solana_message.json similarity index 100% rename from services/opencode/src/test/resources/solana_message.json rename to libs/solana/encoding/src/commonTest/resources/solana_message.json diff --git a/test-vectors/README.md b/test-vectors/README.md index a1088bdd98..a7376d9712 100644 --- a/test-vectors/README.md +++ b/test-vectors/README.md @@ -34,8 +34,8 @@ impl must reproduce the fixtures before the native duplicates are deleted. | `base58.json` | `:libs:encryption:base58` → `testAndroidHostTest` (host JVM) **and** `iosSimulatorArm64Test` (Kotlin/Native) — **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** | +| `solana_message.json` | `:libs:solana:encoding` → `testAndroidHostTest` (host JVM) **and** `iosSimulatorArm64Test`/`macosArm64Test` (Kotlin/Native) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | +| `compact_message.json` | `:libs:solana:encoding` → `testAndroidHostTest` (host JVM) **and** `iosSimulatorArm64Test`/`macosArm64Test` (Kotlin/Native) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | | `kikcode.json` + `kikcode_golden.svg` | `:libs:codes:kikcode` → `testAndroidHostTest` (host JVM) — **green** | *same Kotlin source* → `iosSimulatorArm64Test` (Kotlin/Native) — **green** | Why the iOS split: **ed25519** lives in the standalone `CodeCurves` C package → host `swift test`. @@ -125,8 +125,9 @@ legacy wire format: `header(3B) ‖ shortvec(pubkeys) ‖ blockhash(32B) ‖ sho 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). +layers in one shot. Both sides are pure serialization (host-runnable): Android `:libs:solana:encoding` +`commonTest` (JVM host + Kotlin/Native), iOS `FlipcashCore` on the simulator. Gates **C3** (transaction +serialization). ## compact_message (`compact_message.json`) — C3 intent signing @@ -190,8 +191,8 @@ cp test-vectors/base58.json libs/encryption/base58/src/commonTest/resour 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/ +cp test-vectors/solana_message.json libs/solana/encoding/src/commonTest/resources/ +cp test-vectors/compact_message.json libs/solana/encoding/src/commonTest/resources/ cp test-vectors/kikcode.json test-vectors/kikcode_golden.svg \ libs/codes/kikcode/src/commonTest/resources/ # KMP: one copy serves both platforms From 0f889df2004852ceb23c5d6e5caa00ae74b2b81a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 19:47:27 -0400 Subject: [PATCH 15/26] build: run :libs:encryption:keys host tests via testAndroidHostTest :libs:encryption:keys became a KMP module (com.android.kotlin.multiplatform.library + withHostTest) earlier on this branch but was never added to kmpUnitTestModules, so it fell into androidUnitTestModules and the flipcashTestDebug aggregate would have invoked the nonexistent testDebugUnitTest task for it instead of testAndroidHostTest. Add it to kmpUnitTestModules, ordered after its dependencies (base58, sha256, utils) and before :libs:solana:encoding, which depends on it. --- settings.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/settings.gradle.kts b/settings.gradle.kts index c8e313e2b0..231f69fa95 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -296,6 +296,7 @@ val kmpUnitTestModules = setOf( // (macOS .dylib / Linux .so); it can't load on the Linux CI runner. Its ed25519.json // parity is gated via the iOS cinterop path (macOS) instead — so it's excluded here. ":libs:encryption:utils", + ":libs:encryption:keys", ":libs:solana:encoding", ) // ed25519 and mnemonic excluded: both pull in the JNI-backed Ed25519Kmp Android actual for their From bd650a7ffaa36bed9daeaec47be0b05b4f3440e0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 21:14:00 -0400 Subject: [PATCH 16/26] feat(shared-core): export solana encoding through a flat Data-only entry point Add :libs:solana:encoding and :libs:encryption:keys to shared-core's export/api blocks so the Obj-C framework carries Solana wire-format encode/decode. Exporting the module surfaces its whole public API, including the Message/SolanaTransaction/Instruction/AddressLookupTable hierarchy the Swift FlipcashCore side already defines under the same names. Add SolanaEncoding as the entry point callers actually use: four ByteArray-in/ByteArray-out functions (decode/encode message, decode/encode transaction) that never take or return a Message or SolanaTransaction, so the facade layer never needs the leaked types. --- kmp/shared-core/build.gradle.kts | 4 + .../getcode/opencode/solana/SolanaEncoding.kt | 76 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaEncoding.kt diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts index 219ed71f6f..a00c35d623 100644 --- a/kmp/shared-core/build.gradle.kts +++ b/kmp/shared-core/build.gradle.kts @@ -45,6 +45,8 @@ kotlin { export(project(":libs:encryption:ed25519")) export(project(":libs:encryption:mnemonic")) export(project(":libs:currency-math:discrete-curve")) + export(project(":libs:encryption:keys")) + export(project(":libs:solana:encoding")) } } @@ -59,6 +61,8 @@ kotlin { api(project(":libs:encryption:ed25519")) api(project(":libs:encryption:mnemonic")) api(project(":libs:currency-math:discrete-curve")) + api(project(":libs:encryption:keys")) + api(project(":libs:solana:encoding")) } } } diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaEncoding.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaEncoding.kt new file mode 100644 index 0000000000..c7be54a507 --- /dev/null +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaEncoding.kt @@ -0,0 +1,76 @@ +package com.getcode.opencode.solana + +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.LENGTH_32 +import com.getcode.solana.keys.LENGTH_64 +import com.getcode.solana.keys.Signature + +/** + * Flat, `ByteArray`-only entry point for Solana message + transaction wire-format encode/decode, + * meant to cross the Kotlin/Native Obj-C bridge into `SharedCoreKit` once `:kmp:shared-core` + * exports this module. + * + * Two findings rule out exporting [Message]/[SolanaTransaction]/[Instruction]/[AddressLookupTable] + * directly alongside this object. First, all four names already exist as Swift types in + * `FlipcashCore/Sources/FlipcashCore/Solana/` — the next phase keeps those Swift types and + * re-bodies them rather than replacing them, so exporting the Kotlin types under the same names + * would collide with the types that survive. Second, [Message] is a `sealed interface`, which + * crosses the Obj-C bridge as a protocol — a reference type with no exhaustive `switch` — while + * Swift's `Message` is a value-type `enum`; `recentBlockhash` shows the cost, since the Swift + * setter rebinds `self` with a copy while the Kotlin one mutates the underlying object in place. + * That is a semantics change, not a rename. + * + * So every function here takes and returns `ByteArray` (never `List`, which boxes per + * element crossing the bridge), and the [Message]/[SolanaTransaction] hierarchy never appears in a + * parameter or a return type — callers on the Swift side never hold a Kotlin `Message`. + */ +object SolanaEncoding { + + /** + * Parses [bytes] as a Solana message — legacy or v0, decided by the version-prefix byte the + * wire format already carries — and immediately re-serializes what it parsed. A non-null + * result proves [bytes] is a well-formed message this codec round-trips byte-for-byte; `null` + * means [bytes] did not parse as either version. + */ + fun decodeMessage(bytes: ByteArray): ByteArray? = + Message.newInstance(bytes.toList())?.encode()?.toByteArray() + + /** + * Parses [bytes] as a message, replaces its `recentBlockhash` with [blockhash], and + * re-serializes it. This is `Message.recentBlockhash`'s setter plus `encode()`, exposed as the + * one message mutation a caller actually needs without ever holding a Kotlin `Message`: + * refreshing the blockhash immediately before signing. Returns `null` if [bytes] does not + * parse, or [blockhash] is not exactly 32 bytes. + */ + fun encodeMessage(bytes: ByteArray, blockhash: ByteArray): ByteArray? { + if (blockhash.size != LENGTH_32) return null + val message = Message.newInstance(bytes.toList()) ?: return null + message.recentBlockhash = Hash(blockhash.toList()) + return message.encode().toByteArray() + } + + /** + * Parses [bytes] as a full transaction (signature(s) + message) and immediately re-serializes + * it. A non-null result proves [bytes] is a well-formed transaction this codec round-trips + * byte-for-byte; `null` means [bytes] did not parse. + */ + fun decodeTransaction(bytes: ByteArray): ByteArray? = + SolanaTransaction.fromList(bytes.toList())?.encode()?.toByteArray() + + /** + * Builds transaction wire bytes from an already-encoded [message] and [signatures] — each + * signature 64 bytes, concatenated in signer order, as produced by the ed25519 signing this + * module deliberately does not own (moved to `SolanaTransactionSigning.kt` in + * `:services:opencode` in Phase 3, since `sign()` needs the JNI `Ed25519`, not this module's + * dependencies). Validates [signatures]' length against the message's own required-signature + * count. Returns `null` if [message] does not parse, [signatures] is not a multiple of 64 + * bytes, or the signature count does not match the message's header. + */ + fun encodeTransaction(message: ByteArray, signatures: ByteArray): ByteArray? { + if (signatures.size % LENGTH_64 != 0) return null + val parsedMessage = Message.newInstance(message.toList()) ?: return null + val signatureList = signatures.toList().chunked(LENGTH_64).map { Signature(it) } + if (signatureList.size != parsedMessage.header.requiredSignatures) return null + return SolanaTransaction(parsedMessage, signatureList).encode().toByteArray() + } +} From 533862ed1296308da495153a89785d7a99aebb1b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 21:14:17 -0400 Subject: [PATCH 17/26] feat(shared-core-kit): add Solana encoding facade and vector tests Wrap SharedCore.SolanaEncoding in a Data-in/Data-out SharedSolanaEncoding enum, matching the style of Base58.swift and Derivation.swift. Callers never touch the underlying Kotlin Message/SolanaTransaction types. Copy solana_message.json and compact_message.json from test-vectors/ and assert the same vectors the Kotlin commonTest suite does: decodeMessage/encodeMessage round-trip and mutate the canonical legacy messages byte for byte, and the compact-message byte composition and its SHA-256 are replicated directly since SharedHash.sha256 already crosses the bridge. Transaction encode/decode is exercised against transactions built from those same message vectors, since no canonical fixture covers full transactions yet. --- .../SharedCoreKit/SolanaEncoding.swift | 50 +++++ .../Fixtures/compact_message.json | 58 ++++++ .../Fixtures/solana_message.json | 108 +++++++++++ .../SolanaEncodingTests.swift | 175 ++++++++++++++++++ 4 files changed, 391 insertions(+) create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/SolanaEncoding.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaEncodingTests.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaEncoding.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaEncoding.swift new file mode 100644 index 0000000000..bea04298cc --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaEncoding.swift @@ -0,0 +1,50 @@ +import Foundation +import SharedCore + +/// Solana message + transaction wire-format encode/decode, backed by the shared Kotlin +/// implementation in `:libs:solana:encoding`. +/// +/// This wraps `SharedCore.SolanaEncoding` — a flat, `Data`-in/`Data`-out entry point deliberately +/// kept separate from the Kotlin `Message`/`SolanaTransaction`/`Instruction`/`AddressLookupTable` +/// hierarchy, since those names already exist as Swift types in +/// `FlipcashCore/Sources/FlipcashCore/Solana/` and Kotlin's `Message` is a `sealed interface` +/// (an Obj-C protocol, reference-typed) where Swift's is a value-type `enum`. Nothing here ever +/// holds a Kotlin `Message` — every call is bytes in, bytes out. +public enum SharedSolanaEncoding { + + /// Parses `bytes` as a Solana message — legacy or v0, decided by the version-prefix byte the + /// wire format already carries — and immediately re-serializes what it parsed. A non-nil + /// result proves `bytes` is a well-formed message this codec round-trips byte-for-byte; `nil` + /// means `bytes` did not parse as either version. + public static func decodeMessage(_ bytes: Data) -> Data? { + guard let result = SolanaEncoding.shared.decodeMessage(bytes: bytes.kotlinByteArray) else { return nil } + return Data(result) + } + + /// Parses `bytes` as a message, replaces its `recentBlockhash` with `blockhash`, and + /// re-serializes it — the one message mutation a caller needs without ever holding a Kotlin + /// `Message`: refreshing the blockhash immediately before signing. Returns `nil` if `bytes` + /// does not parse, or `blockhash` is not exactly 32 bytes. + public static func encodeMessage(_ bytes: Data, blockhash: Data) -> Data? { + guard let result = SolanaEncoding.shared.encodeMessage(bytes: bytes.kotlinByteArray, blockhash: blockhash.kotlinByteArray) else { return nil } + return Data(result) + } + + /// Parses `bytes` as a full transaction (signature(s) + message) and immediately + /// re-serializes it. A non-nil result proves `bytes` is a well-formed transaction this codec + /// round-trips byte-for-byte; `nil` means `bytes` did not parse. + public static func decodeTransaction(_ bytes: Data) -> Data? { + guard let result = SolanaEncoding.shared.decodeTransaction(bytes: bytes.kotlinByteArray) else { return nil } + return Data(result) + } + + /// Builds transaction wire bytes from an already-encoded `message` and `signatures` — each + /// signature 64 bytes, concatenated in signer order. Validates `signatures`' length against + /// the message's own required-signature count. Returns `nil` if `message` does not parse, + /// `signatures` is not a multiple of 64 bytes, or the signature count does not match the + /// message's header. + public static func encodeTransaction(message: Data, signatures: Data) -> Data? { + guard let result = SolanaEncoding.shared.encodeTransaction(message: message.kotlinByteArray, signatures: signatures.kotlinByteArray) else { return nil } + return Data(result) + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json new file mode 100644 index 0000000000..251e2eb6d5 --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/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/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json new file mode 100644 index 0000000000..36f9651f6b --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/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/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaEncodingTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaEncodingTests.swift new file mode 100644 index 0000000000..790be4bc39 --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaEncodingTests.swift @@ -0,0 +1,175 @@ +import Testing +import Foundation +@testable import SharedCoreKit + +/// Asserts the exact vectors `SolanaMessageVectorTest` and `CompactMessageVectorTest` assert in the +/// Kotlin `commonTest` suite (`libs/solana/encoding/src/commonTest/`) — matching guarantees Android +/// and iOS build byte-identical wire data from the same inputs. Fixtures are copies of +/// `test-vectors/solana_message.json` and `test-vectors/compact_message.json`, the same arrangement +/// `Fixtures.swift` describes for every other suite here. +@Suite("SharedSolanaEncoding") +struct SolanaEncodingTests { + + // MARK: - solana_message.json + + // The Kotlin test builds these messages field-by-field (`Instruction`/`AccountMeta`/`Message` + // are deliberately not exported — see `SolanaEncoding.kt`), so this suite instead treats + // `expectedMessage` as the wire bytes and drives them through `decodeMessage`/`encodeMessage`, + // the only Solana-message entry points `SharedCoreKit` exposes. + struct AccountEntry: Decodable { let seed: Int; let role: String } + struct MessageVector: Decodable { + let name: String + let accounts: [AccountEntry] + let blockhashSeed: Int + let expectedMessage: String + } + struct MessageFixture: Decodable { let vectors: [MessageVector] } + + // Legacy message layout: header(3) + short-vec account count(1, true for every fixture here + // since none reaches 128 accounts) + accounts(32 bytes each) + recentBlockhash(32). + private static func blockhashOffset(accountCount: Int) -> Int { 3 + 1 + accountCount * 32 } + + @Test("decodeMessage round-trips the canonical legacy message vectors byte for byte") + func decodeMessageMatchesCanonicalVectors() throws { + let fixture = try Fixtures.load("solana_message", as: MessageFixture.self) + #expect(!fixture.vectors.isEmpty, "no vectors loaded") + + for v in fixture.vectors { + let expected = try #require(Data(hex: v.expectedMessage), "bad hex fixture for \(v.name)") + let decoded = try #require(SharedSolanaEncoding.decodeMessage(expected), "decodeMessage returned nil for \(v.name)") + #expect(decoded == expected, "round-trip mismatch for \(v.name)") + } + } + + @Test("encodeMessage rewrites recentBlockhash in place and nothing else") + func encodeMessageSwapsBlockhash() throws { + let fixture = try Fixtures.load("solana_message", as: MessageFixture.self) + #expect(!fixture.vectors.isEmpty, "no vectors loaded") + + for v in fixture.vectors { + let original = try #require(Data(hex: v.expectedMessage), "bad hex fixture for \(v.name)") + let offset = Self.blockhashOffset(accountCount: v.accounts.count) + + let originalBlockhash = original.subdata(in: offset..<(offset + 32)) + let expectedOriginalBlockhash = Data(repeating: UInt8(truncatingIfNeeded: v.blockhashSeed), count: 32) + #expect(originalBlockhash == expectedOriginalBlockhash, "blockhash offset computed wrong for \(v.name)") + + // Identity swap: putting the vector's own blockhash back must reproduce it exactly. + let identity = try #require(SharedSolanaEncoding.encodeMessage(original, blockhash: originalBlockhash), "encodeMessage returned nil for \(v.name)") + #expect(identity == original, "identity blockhash swap changed bytes for \(v.name)") + + // Real swap: a different blockhash lands at that offset, and nowhere else moves. + let newBlockhash = Data(repeating: 0xAB, count: 32) + let swapped = try #require(SharedSolanaEncoding.encodeMessage(original, blockhash: newBlockhash), "encodeMessage returned nil for \(v.name)") + var expectedSwapped = original + expectedSwapped.replaceSubrange(offset..<(offset + 32), with: newBlockhash) + #expect(swapped == expectedSwapped, "blockhash swap touched bytes outside the blockhash window for \(v.name)") + } + } + + @Test("decodeMessage and encodeMessage reject malformed input") + func rejectsMalformedMessages() { + #expect(SharedSolanaEncoding.decodeMessage(Data()) == nil) + #expect(SharedSolanaEncoding.encodeMessage(Data([0xFF, 0xFF, 0xFF]), blockhash: Data(repeating: 0, count: 32)) == nil) + #expect(SharedSolanaEncoding.encodeMessage(Data(repeating: 0, count: 40), blockhash: Data(repeating: 0, count: 31)) == nil, "31-byte blockhash must be rejected") + } + + // MARK: - compact_message.json + + // `SharedHash.sha256` (from `Hashes.swift`) already crosses the bridge, so this replicates + // `CompactMessageVectorTest`'s byte composition directly rather than adding new Kotlin surface + // for it — the layout itself (field order, "transfer" domain, little-endian amount) is the thing + // under test, and it needs no `Message`/`Instruction` construction on either side. + struct CompactVector: Decodable { + let name: String + let domain: String + let sourceSeed: Int + let destinationSeed: Int + let amount: String? + let nonceSeed: Int + let nonceValueSeed: Int + let message: String + let sha256: String + } + struct CompactFixture: Decodable { let vectors: [CompactVector] } + + private static func key(_ seed: Int) -> Data { + Data(repeating: UInt8(truncatingIfNeeded: seed), count: 32) + } + + private static func amountLE8(_ value: String) throws -> Data { + let v = try #require(UInt64(value)) + return Data((0..<8).map { UInt8((v >> (8 * $0)) & 0xFF) }) + } + + @Test("compact message bytes and sha256 match the canonical vectors") + func compactMessageMatchesCanonicalVectors() throws { + let fixture = try Fixtures.load("compact_message", as: CompactFixture.self) + #expect(!fixture.vectors.isEmpty, "no vectors loaded") + + for v in fixture.vectors { + var message = Data() + message.append(Data(v.domain.utf8)) + message.append(Self.key(v.sourceSeed)) + message.append(Self.key(v.destinationSeed)) + if let amount = v.amount { + message.append(try Self.amountLE8(amount)) + } + message.append(Self.key(v.nonceSeed)) + message.append(Self.key(v.nonceValueSeed)) + + #expect(message.hexString == v.message, "message bytes mismatch for \(v.name)") + #expect(SharedHash.sha256(message).hexString == v.sha256, "sha256 mismatch for \(v.name)") + } + } + + // MARK: - transaction encode/decode + + // No canonical cross-platform fixture covers full transactions (signature + message), so this + // builds one from each solana_message.json vector: zero signatures, one per required signer, + // per the wire format's own convention for an unsigned transaction (`Signature.zero` in Kotlin). + @Test("encodeTransaction assembles, and decodeTransaction round-trips, a transaction built on a canonical message") + func transactionRoundTrips() throws { + let fixture = try Fixtures.load("solana_message", as: MessageFixture.self) + #expect(!fixture.vectors.isEmpty, "no vectors loaded") + + for v in fixture.vectors { + let message = try #require(Data(hex: v.expectedMessage), "bad hex fixture for \(v.name)") + // The message's own header's first byte is `requiredSignatures` (`expectedHeader`'s + // first byte, verified equal to this in the Kotlin fixture already). + let requiredSignatures = Int(message[message.startIndex]) + #expect(requiredSignatures > 0, "expected at least one required signer for \(v.name)") + + let signatures = Data(repeating: 0, count: requiredSignatures * 64) + // Transactions are short-vec(signature count) + signatures + message; every vector here + // has fewer than 128 signers, so the short-vec length is the single count byte. + var expectedTransaction = Data([UInt8(requiredSignatures)]) + expectedTransaction.append(signatures) + expectedTransaction.append(message) + + let assembled = try #require(SharedSolanaEncoding.encodeTransaction(message: message, signatures: signatures), "encodeTransaction returned nil for \(v.name)") + #expect(assembled == expectedTransaction, "encodeTransaction mismatch for \(v.name)") + + let decoded = try #require(SharedSolanaEncoding.decodeTransaction(assembled), "decodeTransaction returned nil for \(v.name)") + #expect(decoded == assembled, "decodeTransaction round-trip mismatch for \(v.name)") + } + } + + @Test("encodeTransaction and decodeTransaction reject malformed input") + func rejectsMalformedTransactions() throws { + let fixture = try Fixtures.load("solana_message", as: MessageFixture.self) + let vector = try #require(fixture.vectors.first) + let message = try #require(Data(hex: vector.expectedMessage)) + let requiredSignatures = Int(message[message.startIndex]) + + // Wrong signature count for this message's header. + #expect(SharedSolanaEncoding.encodeTransaction(message: message, signatures: Data(repeating: 0, count: (requiredSignatures + 1) * 64)) == nil) + // Signature payload not a multiple of 64 bytes. + #expect(SharedSolanaEncoding.encodeTransaction(message: message, signatures: Data(repeating: 0, count: 10)) == nil) + // Unparseable message. + #expect(SharedSolanaEncoding.encodeTransaction(message: Data([0xFF]), signatures: Data()) == nil) + // Zero-signature short-vec header with no message bytes behind it: parses as far as the + // signature count, then fails to find a message. + #expect(SharedSolanaEncoding.decodeTransaction(Data([0x00])) == nil) + } +} From 05a6f7f5768f9268ffe423fe57a0a4402b019910 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 12 Sep 2026 08:50:24 -0400 Subject: [PATCH 18/26] feat(shared-core-kit): add Solana message/transaction type facade SharedCoreKit's Solana surface was flat Data-in/Data-out only (SolanaEncoding.swift), enough to round-trip bytes but not to build or inspect a message. FlipcashCore needs to construct transactions field by field (accounts, header, instructions, address-table lookups) to eventually delegate to this framework instead of its own parallel Swift implementation. Add SharedSolanaMessage (a value-type enum standing in for Kotlin's Message sealed interface, which crosses the Obj-C bridge as a reference-type protocol, plus SharedSolanaLegacyMessage, SharedSolanaVersionedMessageV0, SharedSolanaAccountMeta, SharedSolanaInstruction, SharedSolanaCompiledInstruction, SharedSolanaMessageAddressTableLookup, SharedSolanaAddressLookupTable, and SharedSolanaTransaction. Transaction construction delegates to the exported SolanaTransaction.doNewInstance/doNewV0Instance factories, so the canonical account-sort and V0 address-lookup-table grouping stays single-sourced in Kotlin rather than reimplemented in Swift. This hierarchy is the one part of the exported surface with no unboxed-ByteArray entry point (KeyType/AccountMeta/Instruction only take List, which bridges as boxed KotlinByte per element), so KotlinByteList+Bridge.swift adds that boxing/unboxing in one place. Guard SharedSolanaTransaction.init(data:) against empty input directly: SolanaTransaction.fromList reads its leading ShortVec length byte with no bounds check (ShortVec.decodeLen), so empty data crashes the process instead of returning nil. EOF ) --- .../SharedCoreKit/KotlinByteList+Bridge.swift | 40 ++ .../Sources/SharedCoreKit/SolanaMessage.swift | 465 ++++++++++++++++++ .../SharedCoreKit/SolanaTransaction.swift | 116 +++++ .../SolanaMessageTests.swift | 176 +++++++ 4 files changed, 797 insertions(+) create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/KotlinByteList+Bridge.swift create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinByteList+Bridge.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinByteList+Bridge.swift new file mode 100644 index 0000000000..16d78c8879 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinByteList+Bridge.swift @@ -0,0 +1,40 @@ +import Foundation +import SharedCore + +/// Boxed-`List` bridging, needed only by the Solana message/transaction type hierarchy. +/// +/// Every other flat entry point in this package (`SolanaEncoding`, `Ed25519Kmp`, `Base58`, ...) takes +/// `ByteArray`, which bridges to `KotlinByteArray` — unboxed, one copy. The `Message`/`Instruction`/ +/// `AccountMeta`/`Key32`/`PublicKey`/`Signature` family is the one part of the exported surface with +/// no such entry point: their constructors and `List`-typed properties only exist as +/// `List`, which crosses the bridge as `NSArray` — one boxed `NSNumber` subclass +/// instance per byte. `KeyType.byteArray` is the one exception (an unboxed read-only property), so +/// reading a key back out is cheap; constructing one from raw bytes is not. +extension Data { + + /// Boxes every byte — the shape `Key32`/`PublicKey`/`Signature`/`AccountMeta`'s constructors + /// require, since none of them has a raw-`ByteArray` overload. + var kotlinByteList: [KotlinByte] { + map { KotlinByte(value: Int8(bitPattern: $0)) } + } + + /// Unboxes a `List` result (e.g. `Instruction.data`, `CompiledInstruction.encode()`) back + /// into `Data`. + init(kotlinByteList array: [KotlinByte]) { + self.init(array.map { UInt8(bitPattern: $0.int8Value) }) + } +} + +extension Array where Element == UInt8 { + + /// Boxes index bytes (`CompiledInstruction.accountIndexes`, LUT `writableIndexes`/ + /// `readonlyIndexes`) for the same reason as `Data.kotlinByteList` — these are `List` on + /// the Kotlin side too, just not 32/64-byte keys. + var kotlinByteList: [KotlinByte] { + map { KotlinByte(value: Int8(bitPattern: $0)) } + } + + init(kotlinByteList array: [KotlinByte]) { + self = array.map { UInt8(bitPattern: $0.int8Value) } + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift new file mode 100644 index 0000000000..2cb005c7ea --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift @@ -0,0 +1,465 @@ +import Foundation +import SharedCore + +/// Swift-idiomatic facade over Kotlin's Solana message/instruction/account type hierarchy in +/// `:libs:solana:encoding`, for callers that need to construct, inspect, and mutate a message — +/// not just round-trip its bytes (see `SolanaEncoding.swift` for that flat entry point). +/// +/// Every type here is a plain Swift value type; none of them stores a live Kotlin object. Kotlin's +/// `AccountMeta`/`Instruction`/`CompiledInstruction`/`MessageHeader`/`LegacyMessage`/ +/// `VersionedMessageV0` are all mutable reference types crossing the bridge, and Kotlin's `Message` +/// is a `sealed interface` (an Obj-C protocol) rather than a value type — holding on to any of them +/// directly would reintroduce reference semantics and non-exhaustive `switch` into Swift. Instead, +/// every Kotlin object is built fresh at the point it's needed (`encode()`, a constructor call) and +/// discarded immediately after; the `kotlin`/`init(_:)` pairs below are the only places that happen. +/// +/// `PublicKey`/`Hash`/`Signature` all appear here as plain 32- or 64-byte `Data`, never as a +/// dedicated wrapper type: Kotlin's `Hash` is a `typealias` for `Key32` with no ObjC class of its +/// own, and modeling `PublicKey` as anything other than `Data` would just be a second wrapper for +/// FlipcashCore's existing one to unwrap. Constructing any of `Key32`/`PublicKey`/`Signature` from +/// raw bytes boxes every byte (`KotlinByteList+Bridge.swift`) because none of them has a +/// raw-`ByteArray` initializer — only `KeyType.byteArray` (reading one back out) is unboxed. + +// MARK: - Header + +public struct SharedSolanaMessageHeader: Equatable, Sendable { + public var requiredSignatures: Int + public var readOnlySigners: Int + public var readOnly: Int + + public init(requiredSignatures: Int, readOnlySigners: Int, readOnly: Int) { + self.requiredSignatures = requiredSignatures + self.readOnlySigners = readOnlySigners + self.readOnly = readOnly + } +} + +extension SharedSolanaMessageHeader { + init(_ header: SharedCore.MessageHeader) { + self.init( + requiredSignatures: Int(header.requiredSignatures), + readOnlySigners: Int(header.readOnlySigners), + readOnly: Int(header.readOnly) + ) + } + + var kotlin: SharedCore.MessageHeader { + SharedCore.MessageHeader( + requiredSignatures: Int32(requiredSignatures), + readOnlySigners: Int32(readOnlySigners), + readOnly: Int32(readOnly) + ) + } +} + +// MARK: - Version + +/// Mirrors Kotlin's `MessageVersion` enum class — the one-byte version tag a `Message` carries. +public enum SharedSolanaMessageVersion: Sendable { + case legacy + case v0 +} + +// MARK: - Account meta + +public struct SharedSolanaAccountMeta: Equatable, Sendable { + public var publicKey: Data + public var isSigner: Bool + public var isWritable: Bool + public var isPayer: Bool + public var isProgram: Bool + + public init(publicKey: Data, isSigner: Bool, isWritable: Bool, isPayer: Bool, isProgram: Bool) { + self.publicKey = publicKey + self.isSigner = isSigner + self.isWritable = isWritable + self.isPayer = isPayer + self.isProgram = isProgram + } + + // Mirrors `AccountMeta.Companion`'s factories exactly (plain field assignment — nothing here + // is a Kotlin call, since there is no algorithm to diverge on). + public static func payer(publicKey: Data) -> SharedSolanaAccountMeta { + SharedSolanaAccountMeta(publicKey: publicKey, isSigner: true, isWritable: true, isPayer: true, isProgram: false) + } + + public static func writable(publicKey: Data, signer: Bool = false) -> SharedSolanaAccountMeta { + SharedSolanaAccountMeta(publicKey: publicKey, isSigner: signer, isWritable: true, isPayer: false, isProgram: false) + } + + public static func readonly(publicKey: Data, signer: Bool = false) -> SharedSolanaAccountMeta { + SharedSolanaAccountMeta(publicKey: publicKey, isSigner: signer, isWritable: false, isPayer: false, isProgram: false) + } + + public static func program(publicKey: Data) -> SharedSolanaAccountMeta { + SharedSolanaAccountMeta(publicKey: publicKey, isSigner: false, isWritable: false, isPayer: false, isProgram: true) + } +} + +extension SharedSolanaAccountMeta { + init(_ meta: SharedCore.AccountMeta) { + self.init( + publicKey: Data(meta.publicKey.byteArray), + isSigner: meta.isSigner, + isWritable: meta.isWritable, + isPayer: meta.isPayer, + isProgram: meta.isProgram + ) + } + + var kotlin: SharedCore.AccountMeta { + SharedCore.AccountMeta( + publicKey: SharedCore.PublicKey(bytes: publicKey.kotlinByteList), + isSigner: isSigner, + isWritable: isWritable, + isPayer: isPayer, + isProgram: isProgram + ) + } +} + +// MARK: - Instructions + +/// An uncompiled instruction: accounts referenced by full `AccountMeta`, not by index. This is what +/// a caller builds by hand and what `LegacyMessage.instructions` stores; `compile(messageAccounts:)` +/// turns it into a `CompiledInstruction` against a specific account ordering. +public struct SharedSolanaInstruction: Equatable, Sendable { + public var program: Data + public var accounts: [SharedSolanaAccountMeta] + public var data: Data + + public init(program: Data, accounts: [SharedSolanaAccountMeta], data: Data) { + self.program = program + self.accounts = accounts + self.data = data + } + + public func compile(messageAccounts: [Data]) -> SharedSolanaCompiledInstruction { + let accounts = messageAccounts.map { SharedCore.PublicKey(bytes: $0.kotlinByteList) } + return SharedSolanaCompiledInstruction(kotlin.compile(messageAccounts: accounts)) + } +} + +extension SharedSolanaInstruction { + init(_ instruction: SharedCore.Instruction) { + self.init( + program: Data(instruction.program.byteArray), + accounts: instruction.accounts.map(SharedSolanaAccountMeta.init), + data: Data(kotlinByteList: instruction.data) + ) + } + + var kotlin: SharedCore.Instruction { + SharedCore.Instruction( + program: SharedCore.PublicKey(bytes: program.kotlinByteList), + accounts: accounts.map { $0.kotlin }, + data: data.kotlinByteList + ) + } +} + +/// A compiled instruction: accounts referenced by index into the enclosing message's account list. +/// This is the only instruction shape `VersionedMessageV0` stores, and what `Message.instructions` +/// (the unified, cross-version view) always returns. +public struct SharedSolanaCompiledInstruction: Equatable, Sendable { + public var programIndex: UInt8 + public var accountIndexes: [UInt8] + public var data: Data + + public init(programIndex: UInt8, accountIndexes: [UInt8], data: Data) { + self.programIndex = programIndex + self.accountIndexes = accountIndexes + self.data = data + } + + /// Parses a single wire-format compiled instruction — `programIndex(1) + shortVec(accountIndexes) + + /// shortVec(data)` — with no enclosing message. `nil` if `data` is short or malformed. + public init?(data: Data) { + guard let result = SharedCore.CompiledInstruction.companion.fromList(list: data.kotlinByteList) else { return nil } + self.init(result) + } + + public func encode() -> Data { + Data(kotlinByteList: kotlin.encode()) + } + + /// Resolves indexes back to full account references against `accounts`. `nil` if `accounts` is + /// too short for the indexes this instruction carries. + public func decompile(accounts: [SharedSolanaAccountMeta]) -> SharedSolanaInstruction? { + guard let result = kotlin.decompile(accounts: accounts.map { $0.kotlin }) else { return nil } + return SharedSolanaInstruction(result) + } +} + +extension SharedSolanaCompiledInstruction { + init(_ instruction: SharedCore.CompiledInstruction) { + self.init( + programIndex: UInt8(bitPattern: instruction.programIndex), + accountIndexes: [UInt8](kotlinByteList: instruction.accountIndexes), + data: Data(kotlinByteList: instruction.data) + ) + } + + var kotlin: SharedCore.CompiledInstruction { + SharedCore.CompiledInstruction( + programIndex: Int8(bitPattern: programIndex), + accountIndexes: accountIndexes.kotlinByteList, + data: data.kotlinByteList + ) + } +} + +// MARK: - Address lookup tables + +/// An entry in a `VersionedMessageV0`'s `addressLookupTables` — which on-chain table, and which of +/// its indexes this message loads as writable vs. read-only. Mirrors Kotlin's +/// `MessageAddressLookupTable` (internal name; FlipcashCore calls the equivalent +/// `MessageAddressTableLookup`). +public struct SharedSolanaMessageAddressTableLookup: Equatable, Sendable { + public var publicKey: Data + public var writableIndexes: [UInt8] + public var readonlyIndexes: [UInt8] + + public init(publicKey: Data, writableIndexes: [UInt8], readonlyIndexes: [UInt8]) { + self.publicKey = publicKey + self.writableIndexes = writableIndexes + self.readonlyIndexes = readonlyIndexes + } + + public func encode() -> Data { + Data(kotlinByteList: kotlin.encode()) + } +} + +extension SharedSolanaMessageAddressTableLookup { + init(_ lookup: SharedCore.MessageAddressLookupTable) { + self.init( + publicKey: Data(lookup.publicKey.byteArray), + writableIndexes: [UInt8](kotlinByteList: lookup.writableIndexes), + readonlyIndexes: [UInt8](kotlinByteList: lookup.readonlyIndexes) + ) + } + + var kotlin: SharedCore.MessageAddressLookupTable { + SharedCore.MessageAddressLookupTable( + publicKey: SharedCore.PublicKey(bytes: publicKey.kotlinByteList), + writableIndexes: writableIndexes.kotlinByteList, + readonlyIndexes: readonlyIndexes.kotlinByteList + ) + } +} + +/// A full on-chain address lookup table — input to V0 transaction construction +/// (`SharedSolanaTransaction.init(payer:recentBlockhash:addressLookupTables:instructions:)`), not +/// something a decoded message ever hands back (a decoded `VersionedMessageV0` only knows the +/// indexes it used — `addressTableLookups` — never the table's full address list). Mirrors Kotlin's +/// `com.getcode.opencode.model.transactions.AddressLookupTable`. +public struct SharedSolanaAddressLookupTable: Equatable, Sendable { + public var publicKey: Data + public var addresses: [Data] + + public init(publicKey: Data, addresses: [Data]) { + self.publicKey = publicKey + self.addresses = addresses + } +} + +extension SharedSolanaAddressLookupTable { + var kotlin: SharedCore.AddressLookupTable { + SharedCore.AddressLookupTable( + publicKey: SharedCore.PublicKey(bytes: publicKey.kotlinByteList), + addresses: addresses.map { SharedCore.PublicKey(bytes: $0.kotlinByteList) } + ) + } +} + +// MARK: - Legacy message + +public struct SharedSolanaLegacyMessage: Equatable, Sendable { + public var header: SharedSolanaMessageHeader + public var accounts: [SharedSolanaAccountMeta] + public var recentBlockhash: Data + public var instructions: [SharedSolanaInstruction] + + public init(header: SharedSolanaMessageHeader, accounts: [SharedSolanaAccountMeta], recentBlockhash: Data, instructions: [SharedSolanaInstruction]) { + self.header = header + self.accounts = accounts + self.recentBlockhash = recentBlockhash + self.instructions = instructions + } + + public func encode() -> Data { + Data(kotlin.encode()) + } +} + +extension SharedSolanaLegacyMessage { + init(_ message: SharedCore.LegacyMessage) { + self.init( + header: SharedSolanaMessageHeader(message.header), + accounts: message.accounts.map(SharedSolanaAccountMeta.init), + recentBlockhash: Data(message.recentBlockhash.byteArray), + instructions: message.instructions.map(SharedSolanaInstruction.init) + ) + } + + var kotlin: SharedCore.LegacyMessage { + SharedCore.LegacyMessage( + header: header.kotlin, + accounts: accounts.map { $0.kotlin }, + recentBlockhash: SharedCore.Key32(bytes: recentBlockhash.kotlinByteList), + instructions: instructions.map { $0.kotlin } + ) + } +} + +// MARK: - Versioned V0 message + +public struct SharedSolanaVersionedMessageV0: Equatable, Sendable { + public var header: SharedSolanaMessageHeader + public var staticAccountKeys: [Data] + public var recentBlockhash: Data + public var instructions: [SharedSolanaCompiledInstruction] + public var addressLookupTables: [SharedSolanaMessageAddressTableLookup] + + public init( + header: SharedSolanaMessageHeader, + staticAccountKeys: [Data], + recentBlockhash: Data, + instructions: [SharedSolanaCompiledInstruction], + addressLookupTables: [SharedSolanaMessageAddressTableLookup] + ) { + self.header = header + self.staticAccountKeys = staticAccountKeys + self.recentBlockhash = recentBlockhash + self.instructions = instructions + self.addressLookupTables = addressLookupTables + } + + public func encode() -> Data { + Data(kotlinByteList: kotlin.encode()) + } +} + +extension SharedSolanaVersionedMessageV0 { + init(_ message: SharedCore.VersionedMessageV0) { + self.init( + header: SharedSolanaMessageHeader(message.header), + staticAccountKeys: message.staticAccountKeys.map { Data($0.byteArray) }, + recentBlockhash: Data(message.recentBlockhash.byteArray), + instructions: message.instructions.map(SharedSolanaCompiledInstruction.init), + addressLookupTables: message.addressLookupTables.map(SharedSolanaMessageAddressTableLookup.init) + ) + } + + var kotlin: SharedCore.VersionedMessageV0 { + SharedCore.VersionedMessageV0( + header: header.kotlin, + staticAccountKeys: staticAccountKeys.map { SharedCore.PublicKey(bytes: $0.kotlinByteList) }, + recentBlockhash: SharedCore.Key32(bytes: recentBlockhash.kotlinByteList), + instructions: instructions.map { $0.kotlin }, + addressLookupTables: addressLookupTables.map { $0.kotlin } + ) + } +} + +// MARK: - Message + +/// A Solana message, legacy or versioned-V0. This is the Swift value-type replacement for Kotlin's +/// `Message` `sealed interface` (an Obj-C protocol — reference-typed, no exhaustive `switch`): +/// exactly the two cases Kotlin's `sealed interface` permits, so a `switch` over this enum is +/// exhaustive the same way a `when` over the Kotlin type is. +/// +/// `recentBlockhash`'s setter reassigns `self` with a copy carrying the new value, where Kotlin's +/// setter mutates the wrapped message object in place. Both are observably equivalent for every +/// caller that only ever holds one reference to the message (the overwhelmingly common case — +/// refreshing a blockhash immediately before signing), but two Kotlin references to the same +/// `Message` would observe each other's mutation where two Swift copies of this enum would not. +/// That is the one place this type cannot losslessly mirror the Kotlin protocol's reference +/// semantics — a deliberate trade for the value semantics constraint #3 asks for. +public enum SharedSolanaMessage: Equatable, Sendable { + case legacy(SharedSolanaLegacyMessage) + case versionedV0(SharedSolanaVersionedMessageV0) + + public var version: SharedSolanaMessageVersion { + switch self { + case .legacy: return .legacy + case .versionedV0: return .v0 + } + } + + public var header: SharedSolanaMessageHeader { + switch self { + case .legacy(let message): return message.header + case .versionedV0(let message): return message.header + } + } + + public var accountKeys: [Data] { + switch self { + case .legacy(let message): return message.accounts.map(\.publicKey) + case .versionedV0(let message): return message.staticAccountKeys + } + } + + public var recentBlockhash: Data { + get { + switch self { + case .legacy(let message): return message.recentBlockhash + case .versionedV0(let message): return message.recentBlockhash + } + } + set { + switch self { + case .legacy(var message): + message.recentBlockhash = newValue + self = .legacy(message) + case .versionedV0(var message): + message.recentBlockhash = newValue + self = .versionedV0(message) + } + } + } + + /// Compiled instructions, uniform across both message kinds — mirrors the Kotlin protocol's own + /// computed property, which recompiles a `Legacy` message's instructions against `accountKeys` + /// on every access rather than caching them. + public var instructions: [SharedSolanaCompiledInstruction] { + switch self { + case .legacy(let message): + let accounts = message.accounts.map(\.publicKey) + return message.instructions.map { $0.compile(messageAccounts: accounts) } + case .versionedV0(let message): + return message.instructions + } + } + + /// Empty for `.legacy` — address lookup tables are a V0-only concept. + public var addressTableLookups: [SharedSolanaMessageAddressTableLookup] { + switch self { + case .legacy: return [] + case .versionedV0(let message): return message.addressLookupTables + } + } + + public func encode() -> Data { + switch self { + case .legacy(let message): return message.encode() + case .versionedV0(let message): return message.encode() + } + } + + /// Parses `data` as a message — legacy or v0, decided by the version-prefix byte. `nil` if + /// `data` matches neither wire format. + public init?(data: Data) { + guard let result = SharedCore.MessageCompanion.shared.doNewInstance(data: data.kotlinByteList) else { return nil } + switch result { + case let legacy as SharedCore.MessageLegacy: + self = .legacy(SharedSolanaLegacyMessage(legacy.message)) + case let v0 as SharedCore.MessageVersionedV0: + self = .versionedV0(SharedSolanaVersionedMessageV0(v0.message)) + default: + return nil + } + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift new file mode 100644 index 0000000000..89c0bb715f --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift @@ -0,0 +1,116 @@ +import Foundation +import SharedCore + +/// A signed (or partially-signed) Solana transaction: a `SharedSolanaMessage` plus its signatures, +/// in signer order. Backed by Kotlin's `SolanaTransaction`. +/// +/// Construction deliberately does not reimplement Kotlin's canonical account-sort/dedup and, +/// for v0, address-lookup-table index-grouping — `SolanaTransaction.Companion.newInstance` / +/// `.newV0Instance` already do that correctly, and are exported. The two `init(payer:...)` +/// overloads below call straight into them so this facade cannot drift from that algorithm. +public struct SharedSolanaTransaction: Equatable, Sendable { + public var message: SharedSolanaMessage + public var signatures: [Data] + + public init(message: SharedSolanaMessage, signatures: [Data]) { + self.message = message + self.signatures = signatures + } + + /// The transaction's own identifying signature — the first signer's signature, once present. + public var identifier: Data { + Data(kotlin.identifier.byteArray) + } + + public var recentBlockhash: Data { + get { message.recentBlockhash } + set { message.recentBlockhash = newValue } + } + + public func encode() -> Data { + Data(kotlinByteList: kotlin.encode()) + } + + /// Parses `data` as a full transaction (signatures + message). `nil` if `data` does not parse + /// as either message version, or its signature count doesn't match the message header. + /// + /// Guards `data.isEmpty` itself rather than forwarding it to Kotlin: `SolanaTransaction.fromList` + /// reads its leading ShortVec length byte with no bounds check + /// (`ShortVec.decodeLen`, `libs/solana/encoding/.../internal/solana/ShortVec.kt`), so an empty + /// list crashes the process (an uncaught `IndexOutOfBoundsException`, not a Swift `nil`) instead + /// of failing the way this initializer's signature promises. This one-line guard only covers the + /// fully-empty case; a truncated ShortVec whose last byte still has its continuation bit set + /// (e.g. a lone `0xFF`) hits the same unbounded read one byte later and is not guarded here — see + /// the facade's final report for why a complete fix belongs in `ShortVec.decodeLen`, not here. + public init?(data: Data) { + guard !data.isEmpty else { return nil } + guard let result = SharedCore.SolanaTransaction.companion.fromList(list: data.kotlinByteList) else { return nil } + self.init(result) + } + + /// Builds an unsigned legacy transaction from `instructions`, applying Kotlin's canonical + /// account sort (payer first, programs last, signers before non-signers, writable before + /// read-only, lexicographic tie-break) and de-duplication. `signatures` starts as one + /// all-zero placeholder per required signer, matching Kotlin's factory. + public init(payer: Data, recentBlockhash: Data?, instructions: SharedSolanaInstruction...) { + self.init(payer: payer, recentBlockhash: recentBlockhash, instructions: instructions) + } + + public init(payer: Data, recentBlockhash: Data?, instructions: [SharedSolanaInstruction]) { + let result = SharedCore.SolanaTransaction.companion.doNewInstance( + payer: SharedCore.PublicKey(bytes: payer.kotlinByteList), + recentBlockhash: recentBlockhash.map { SharedCore.Key32(bytes: $0.kotlinByteList) }, + instructions: instructions.map { $0.kotlin } + ) + self.init(result) + } + + /// Builds an unsigned v0 transaction, resolving `instructions`' accounts against + /// `addressLookupTables` where possible (accounts not covered by any table stay static). + /// Kotlin's factory does the table/index-grouping; this only marshals inputs and outputs. + public init(payer: Data, recentBlockhash: Data?, addressLookupTables: [SharedSolanaAddressLookupTable], instructions: SharedSolanaInstruction...) { + self.init(payer: payer, recentBlockhash: recentBlockhash, addressLookupTables: addressLookupTables, instructions: instructions) + } + + public init(payer: Data, recentBlockhash: Data?, addressLookupTables: [SharedSolanaAddressLookupTable], instructions: [SharedSolanaInstruction]) { + let result = SharedCore.SolanaTransaction.companion.doNewV0Instance( + payer: SharedCore.PublicKey(bytes: payer.kotlinByteList), + recentBlockhash: recentBlockhash.map { SharedCore.Key32(bytes: $0.kotlinByteList) }, + addressLookupTables: addressLookupTables.map { $0.kotlin }, + instructions: instructions.map { $0.kotlin } + ) + self.init(result) + } +} + +extension SharedSolanaTransaction { + init(_ transaction: SharedCore.SolanaTransaction) { + let message: SharedSolanaMessage + switch transaction.message { + case let legacy as SharedCore.MessageLegacy: + message = .legacy(SharedSolanaLegacyMessage(legacy.message)) + case let v0 as SharedCore.MessageVersionedV0: + message = .versionedV0(SharedSolanaVersionedMessageV0(v0.message)) + default: + preconditionFailure("SharedCore.Message has only Legacy and VersionedV0 cases") + } + self.init( + message: message, + signatures: transaction.signatures.map { Data($0.byteArray) } + ) + } + + var kotlin: SharedCore.SolanaTransaction { + let kotlinMessage: SharedCore.Message + switch message { + case .legacy(let m): + kotlinMessage = SharedCore.MessageLegacy(message: m.kotlin) + case .versionedV0(let m): + kotlinMessage = SharedCore.MessageVersionedV0(message: m.kotlin) + } + return SharedCore.SolanaTransaction( + message: kotlinMessage, + signatures: signatures.map { SharedCore.Signature(bytes: $0.kotlinByteList) } + ) + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift new file mode 100644 index 0000000000..6d210cf934 --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift @@ -0,0 +1,176 @@ +import Testing +import Foundation +@testable import SharedCoreKit + +/// Exercises the `SharedSolanaMessage`/`SharedSolanaTransaction` type-hierarchy facade +/// (`SolanaMessage.swift`, `SolanaTransaction.swift`) against the same `solana_message.json` +/// vectors `SolanaEncodingTests` drives through the flat byte-in/byte-out entry points. Where that +/// suite only proves the codec round-trips bytes, this one proves the constructed *types* — headers, +/// accounts, instructions — match what the fixture describes, and that building a transaction from +/// those same fields (payer/writable/readonly/readonly-signer/readonly-program roles) reproduces the +/// fixture's `expectedMessage` byte for byte. +@Suite("SharedSolanaMessage") +struct SolanaMessageTests { + + struct AccountEntry: Decodable { let seed: Int; let role: String } + struct InstructionEntry: Decodable { let programSeed: Int; let accountSeeds: [Int]; let data: String } + struct MessageVector: Decodable { + let name: String + let accounts: [AccountEntry] + let blockhashSeed: Int + let instructions: [InstructionEntry] + let expectedHeader: String + let expectedMessage: String + } + struct MessageFixture: Decodable { let vectors: [MessageVector] } + + private static func key(_ seed: Int) -> Data { + Data(repeating: UInt8(truncatingIfNeeded: seed), count: 32) + } + + /// One `SharedSolanaAccountMeta` per fixture role. `readonly-program` accounts are not part of + /// any instruction's `accounts` array in the fixture (they're only ever the instruction's + /// `program`), but they still belong to the message's own account list — mirroring the Kotlin + /// `commonTest` vectors this file matches, whose `accounts` array includes the program account + /// alongside payer/writable/readonly entries. + private static func accountMeta(_ entry: AccountEntry) throws -> SharedSolanaAccountMeta { + let publicKey = key(entry.seed) + switch entry.role { + case "payer": return .payer(publicKey: publicKey) + case "writable": return .writable(publicKey: publicKey) + case "readonly": return .readonly(publicKey: publicKey) + case "readonly-signer": return .readonly(publicKey: publicKey, signer: true) + case "readonly-program": return .program(publicKey: publicKey) + default: + Issue.record("unknown role \(entry.role)") + return .readonly(publicKey: publicKey) + } + } + + @Test("SharedSolanaMessage decodes the canonical legacy vectors into matching structural fields") + func decodeMatchesStructuralFields() throws { + let fixture = try Fixtures.load("solana_message", as: MessageFixture.self) + #expect(!fixture.vectors.isEmpty, "no vectors loaded") + + for v in fixture.vectors { + let bytes = try #require(Data(hex: v.expectedMessage), "bad hex fixture for \(v.name)") + let message = try #require(SharedSolanaMessage(data: bytes), "SharedSolanaMessage(data:) returned nil for \(v.name)") + + guard case .legacy(let legacy) = message else { + Issue.record("expected .legacy for \(v.name)") + continue + } + + #expect(message.version == .legacy, "version mismatch for \(v.name)") + + let expectedHeader = try #require(Data(hex: v.expectedHeader), "bad header hex for \(v.name)") + #expect(legacy.header.requiredSignatures == Int(expectedHeader[0]), "requiredSignatures mismatch for \(v.name)") + #expect(legacy.header.readOnlySigners == Int(expectedHeader[1]), "readOnlySigners mismatch for \(v.name)") + #expect(legacy.header.readOnly == Int(expectedHeader[2]), "readOnly mismatch for \(v.name)") + + let expectedAccountKeys = v.accounts.map { Self.key($0.seed) } + #expect(message.accountKeys == expectedAccountKeys, "accountKeys mismatch for \(v.name)") + + let expectedBlockhash = Data(repeating: UInt8(truncatingIfNeeded: v.blockhashSeed), count: 32) + #expect(message.recentBlockhash == expectedBlockhash, "recentBlockhash mismatch for \(v.name)") + + #expect(message.instructions.count == v.instructions.count, "instruction count mismatch for \(v.name)") + for (compiled, entry) in zip(message.instructions, v.instructions) { + let programIndex = expectedAccountKeys.firstIndex(of: Self.key(entry.programSeed)) + #expect(programIndex != nil, "program seed \(entry.programSeed) not in account list for \(v.name)") + #expect(Int(compiled.programIndex) == programIndex, "programIndex mismatch for \(v.name)") + + let expectedIndexes = entry.accountSeeds.map { seed in + UInt8(expectedAccountKeys.firstIndex(of: Self.key(seed))!) + } + #expect(compiled.accountIndexes == expectedIndexes, "accountIndexes mismatch for \(v.name)") + + let expectedData = try #require(Data(hex: entry.data), "bad instruction data hex for \(v.name)") + #expect(compiled.data == expectedData, "instruction data mismatch for \(v.name)") + } + + #expect(message.addressTableLookups.isEmpty, "legacy message must have no address table lookups for \(v.name)") + + // Re-encoding what was just decoded must reproduce the original bytes exactly. + #expect(message.encode() == bytes, "re-encode mismatch for \(v.name)") + #expect(legacy.encode() == bytes, "SharedSolanaLegacyMessage.encode() mismatch for \(v.name)") + } + } + + @Test("constructing a SharedSolanaTransaction from fixture account roles reproduces the canonical wire bytes") + func constructionMatchesCanonicalVectors() throws { + let fixture = try Fixtures.load("solana_message", as: MessageFixture.self) + #expect(!fixture.vectors.isEmpty, "no vectors loaded") + + for v in fixture.vectors { + let expected = try #require(Data(hex: v.expectedMessage), "bad hex fixture for \(v.name)") + + let payerEntry = try #require(v.accounts.first { $0.role == "payer" }, "no payer in \(v.name)") + let metaBySeed = Dictionary(uniqueKeysWithValues: try v.accounts.map { ($0.seed, try Self.accountMeta($0)) }) + + let instructions: [SharedSolanaInstruction] = try v.instructions.map { entry in + let program = try #require(metaBySeed[entry.programSeed], "no account for program seed \(entry.programSeed) in \(v.name)").publicKey + let accounts = try entry.accountSeeds.map { seed in + try #require(metaBySeed[seed], "no account for seed \(seed) in \(v.name)") + } + let data = try #require(Data(hex: entry.data), "bad instruction data hex for \(v.name)") + return SharedSolanaInstruction(program: program, accounts: accounts, data: data) + } + + let recentBlockhash = Data(repeating: UInt8(truncatingIfNeeded: v.blockhashSeed), count: 32) + let transaction = SharedSolanaTransaction( + payer: Self.key(payerEntry.seed), + recentBlockhash: recentBlockhash, + instructions: instructions + ) + + #expect(transaction.message.encode() == expected, "constructed message mismatch for \(v.name)") + #expect(transaction.message.header == SharedSolanaMessageHeader( + requiredSignatures: Int(try #require(Data(hex: v.expectedHeader))[0]), + readOnlySigners: Int(try #require(Data(hex: v.expectedHeader))[1]), + readOnly: Int(try #require(Data(hex: v.expectedHeader))[2]) + ), "header mismatch for \(v.name)") + + // Unsigned by convention: one all-zero placeholder signature per required signer. + #expect(transaction.signatures.count == transaction.message.header.requiredSignatures, "signature count mismatch for \(v.name)") + #expect(transaction.signatures.allSatisfy { $0 == Data(repeating: 0, count: 64) }, "expected placeholder signatures for \(v.name)") + } + } + + @Test("SharedSolanaTransaction round-trips through init(data:) and encode()") + func transactionRoundTrips() throws { + let fixture = try Fixtures.load("solana_message", as: MessageFixture.self) + #expect(!fixture.vectors.isEmpty, "no vectors loaded") + + for v in fixture.vectors { + let message = try #require(Data(hex: v.expectedMessage), "bad hex fixture for \(v.name)") + let requiredSignatures = Int(message[message.startIndex]) + let signatures = Data(repeating: 0, count: requiredSignatures * 64) + + var wire = Data([UInt8(requiredSignatures)]) + wire.append(signatures) + wire.append(message) + + let transaction = try #require(SharedSolanaTransaction(data: wire), "SharedSolanaTransaction(data:) returned nil for \(v.name)") + #expect(transaction.signatures.count == requiredSignatures, "signature count mismatch for \(v.name)") + #expect(transaction.encode() == wire, "encode() round-trip mismatch for \(v.name)") + #expect(transaction.recentBlockhash == Data(repeating: UInt8(truncatingIfNeeded: v.blockhashSeed), count: 32), "recentBlockhash mismatch for \(v.name)") + + if requiredSignatures > 0 { + #expect(transaction.identifier == transaction.signatures[0], "identifier mismatch for \(v.name)") + } + } + } + + @Test("SharedSolanaMessage rejects malformed input") + func rejectsMalformedMessages() { + #expect(SharedSolanaMessage(data: Data()) == nil) + #expect(SharedSolanaMessage(data: Data([0xFF, 0xFF, 0xFF])) == nil) + } + + @Test("SharedSolanaTransaction rejects malformed input") + func rejectsMalformedTransactions() { + #expect(SharedSolanaTransaction(data: Data()) == nil) + #expect(SharedSolanaTransaction(data: Data([0x00])) == nil) + } +} From fbcc840ea8129264dc7d00ded2a9a58057a957d4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 12 Sep 2026 09:21:45 -0400 Subject: [PATCH 19/26] fix(solana): bound-check ShortVec length decoding against malformed input ShortVec.decodeLen read input[offset] in an unbounded loop, throwing IndexOutOfBoundsException on empty input or a truncated length prefix whose last byte still had its continuation bit set. On Kotlin/Native that exception is an uncaught, fatal trap across the Swift interop boundary rather than a catchable error, reachable from a Phantom wallet deeplink (PhantomWalletController) or a malformed server response (IntentExecutor). decodeLen now returns null instead of throwing: on empty input, on a length prefix that runs past 5 continuation bytes without terminating, and on a decoded value that would be negative (a crafted 5th byte can set Int's sign bit). Every call site already propagates that null through its own nullable return, so no public signature changes. Auditing decodeLen's callers turned up two more instances of the same class of bug, untrusted length prefixes used without validating them against the remaining bytes: - MessageHeader.fromList indexes its input unconditionally; both LegacyMessage.newInstance and VersionedMessageV0.newInstance now check the remaining length before calling it instead of changing its signature. - VersionedMessageV0.newInstance decoded static account keys with chunked() plus runCatching { PublicKey(chunk) }, which never throws (PublicKey accepts any-length input), so a truncated key list was silently accepted rather than rejected. Switched to the module's bounds-checked DataSlice.chunk, matching how LegacyMessage and SolanaTransaction already decode their fixed-size lists. Bounds checks against attacker-controlled counts use division (count > available divided by size) rather than multiplication (count times size > available), since the multiplication can overflow Int and wrap into a value that passes the check. Adds regression coverage for empty input, a lone unterminated continuation byte, and truncation at each stage of the transaction, legacy message, v0 message, and compiled instruction decoders. --- .../opencode/internal/solana/ShortVec.kt | 23 ++- .../getcode/opencode/solana/Instruction.kt | 8 +- .../getcode/opencode/solana/LegacyMessage.kt | 17 +- .../opencode/solana/SolanaTransaction.kt | 8 +- .../opencode/solana/VersionedMessage.kt | 28 ++- .../opencode/internal/solana/ShortVecTest.kt | 73 ++++++- .../opencode/solana/MalformedDecodeTest.kt | 195 ++++++++++++++++++ 7 files changed, 323 insertions(+), 29 deletions(-) create mode 100644 libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/MalformedDecodeTest.kt diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt index 5727711874..65f2776d78 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/internal/solana/ShortVec.kt @@ -3,17 +3,34 @@ package com.getcode.opencode.internal.solana import com.getcode.utils.DataSlice.tail internal object ShortVec { + + /** + * Maximum number of continuation bytes [decodeLen] will read before giving up. A ShortVec + * length is encoded 7 bits per byte, and [encodeLen] never emits more than 5 bytes for any + * `Int` (5 * 7 = 35 bits comfortably covers all 31 magnitude bits of `Int.MAX_VALUE`), so no + * well-formed input ever needs a 6th byte. Capping the read here rejects a lone dangling + * continuation bit (or a deliberately long run of 0x80-flagged bytes) with `null` instead of + * indexing past the end of [input], and keeps the accumulated `value` from being built from + * more than 32 bits' worth of shifted-in bits. + */ + private const val MAX_LEN_BYTES = 5 + /** * decodeLen decodes a ShortVec encoded length from the [input]. * * @param input - the input list that the length is encoded in - * @return - returns the decoded length of the ShortVec and Offset + * @return - the decoded length of the ShortVec and the remaining bytes after it, or `null` + * if [input] is too short to contain a complete ShortVec length (including an empty list, + * or a final byte whose continuation bit is still set), or if the decoded value would be + * negative (a byte 5's low bits land in `Int`'s sign bit, so a crafted 5-byte sequence can + * otherwise produce a negative "length" — never valid, since a ShortVec length is a count). */ - fun decodeLen(input: List): Pair> { + fun decodeLen(input: List): Pair>? { var offset = 0 var value = 0 while (true) { + if (offset >= input.size || offset >= MAX_LEN_BYTES) return null val byte = input[offset] value = value or (byte.toInt() and 0x7f shl (offset * 7)) @@ -24,6 +41,8 @@ internal object ShortVec { } } + if (value < 0) return null + return Pair(value, input.tail(offset)) } diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt index 5a97ad3c7e..caf678abd6 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt @@ -118,15 +118,15 @@ data class CompiledInstruction( val index = indexConsumed.consumed.first() payload = indexConsumed.remaining - var (accountCount, accountData) = ShortVec.decodeLen(payload) + val (accountCount, accountData) = ShortVec.decodeLen(payload) ?: return null if (accountData.size < accountCount) return null val accountIndexesConsumed = accountData.consume(accountCount) - accountData = accountIndexesConsumed.remaining + val accountIndexesRemaining = accountIndexesConsumed.remaining val accountIndexes = accountIndexesConsumed.consumed - val (opaqueCount, opaqueData) = ShortVec.decodeLen(accountData) - if(opaqueData.size < opaqueCount) return null + val (opaqueCount, opaqueData) = ShortVec.decodeLen(accountIndexesRemaining) ?: return null + if (opaqueData.size < opaqueCount) return null return CompiledInstruction( index, diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt index 6a515d626f..2b6909eb29 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt @@ -47,13 +47,19 @@ data class LegacyMessage( fun newInstance(list: List): LegacyMessage? { var payload: List = list - // Decode `header` + // Decode `header`. Guard the length explicitly: `MessageHeader.fromList` indexes + // data[0..2] with no bounds check of its own (it's a pre-existing non-nullable API, + // left as-is to avoid a public signature change), and `payload.consume` silently + // hands back an empty `consumed` list rather than throwing when `payload` is shorter + // than requested. + if (payload.size < MessageHeader.length) return null val headerConsumed = payload.consume(MessageHeader.length) val header = MessageHeader.fromList(headerConsumed.consumed) payload = headerConsumed.remaining // Decode `accountKeys` - val (accountCount, accountData) = ShortVec.decodeLen(payload) + val (accountCount, accountData) = ShortVec.decodeLen(payload) ?: return null + if (accountCount < 0 || accountCount > accountData.size / com.getcode.solana.keys.LENGTH_32) return null val messageAccounts = accountData.chunk(com.getcode.solana.keys.LENGTH_32, accountCount) { com.getcode.solana.keys.PublicKey( it @@ -63,14 +69,17 @@ data class LegacyMessage( payload = accountData.tail(com.getcode.solana.keys.LENGTH_32 * accountCount) - // Decode `recentBlockHash` + // Decode `recentBlockHash`. `Hash`/`Key32` never validate the size of the bytes handed + // to them, so an under-length `payload` here would silently produce a corrupt hash + // rather than fail — guard the length up front instead. + if (payload.size < com.getcode.solana.keys.LENGTH_32) return null val hashConsumed = payload.consume(com.getcode.solana.keys.LENGTH_32) val hash = com.getcode.solana.keys.Hash(hashConsumed.consumed) payload = hashConsumed.remaining // Decode `instructions` - var (instructionCount, remainingData) = ShortVec.decodeLen(payload) + var (instructionCount, remainingData) = ShortVec.decodeLen(payload) ?: return null val compiledInstructions = mutableListOf() for (i in 0 until instructionCount) { diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt index 003d71a19b..7debb9c2cf 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt @@ -76,9 +76,13 @@ data class SolanaTransaction(val message: Message, val signatures: List): SolanaTransaction? { - val (signatureCount, payload) = ShortVec.decodeLen(list) + val (signatureCount, payload) = ShortVec.decodeLen(list) ?: return null - if (payload.size < signatureCount * LENGTH_64) { + // Bound-check via division rather than `signatureCount * LENGTH_64 > payload.size`: + // `signatureCount` comes straight from the wire and, on a 32-bit `Int`, a large value + // multiplied by LENGTH_64 can wrap around instead of overflowing into a value this + // check would reject. + if (signatureCount < 0 || signatureCount > payload.size / LENGTH_64) { return null } diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/VersionedMessage.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/VersionedMessage.kt index 276283a178..6d9e34918c 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/VersionedMessage.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/VersionedMessage.kt @@ -3,6 +3,7 @@ package com.getcode.opencode.solana import com.getcode.opencode.internal.solana.ShortVec import com.getcode.opencode.internal.solana.model.MessageAddressLookupTable import com.getcode.utils.DataSlice.byteToUnsignedInt +import com.getcode.utils.DataSlice.chunk import com.getcode.utils.DataSlice.consume import com.getcode.utils.DataSlice.prefix import com.getcode.utils.DataSlice.tail @@ -68,21 +69,28 @@ data class VersionedMessageV0( if (version.first().byteToUnsignedInt() != (MessageVersion.v0.ordinal + messageVersionSerializationOffset)) { return null } - // Decode Header (manually, without decompiling instructions) + // Decode Header (manually, without decompiling instructions). Guard the length + // explicitly: `MessageHeader.fromList` indexes data[0..2] with no bounds check of its + // own (it's a pre-existing non-nullable API, left as-is to avoid a public signature + // change), and `payload.consume` silently hands back an empty `consumed` list rather + // than throwing when `payload` is shorter than requested. + if (remainingPayload.size < MessageHeader.length) return null val (headerBytes, remainingPayload1) = remainingPayload.consume(MessageHeader.length) payload = remainingPayload1 val header = MessageHeader.fromList(headerBytes) // Decode static account keys - val (accountCount, accountData) = ShortVec.decodeLen(payload) + val (accountCount, accountData) = ShortVec.decodeLen(payload) ?: return null + if (accountCount < 0 || accountCount > accountData.size / LENGTH_32) return null - val staticKeys = accountData.chunked(LENGTH_32).mapNotNull { chunk -> - runCatching { PublicKey(chunk) }.getOrNull() - } + val staticKeys = accountData.chunk(LENGTH_32, accountCount) { PublicKey(it) } ?: return null payload = accountData.tail(LENGTH_32 * accountCount) - // Decode recent blockhash + // Decode recent blockhash. `Hash`/`Key32` never validate the size of the bytes handed + // to them, so an under-length `payload` here would silently produce a corrupt hash + // rather than fail — guard the length up front instead. + if (payload.size < LENGTH_32) return null val (hashBytes, remainingPayload2) = payload.consume(LENGTH_32) payload = remainingPayload2 val hash = runCatching { Hash(hashBytes) }.getOrNull() @@ -91,7 +99,7 @@ data class VersionedMessageV0( } // Decode compiled instructions (without decompiling yet) - val (instructionCount, instructionsData) = ShortVec.decodeLen(payload) + val (instructionCount, instructionsData) = ShortVec.decodeLen(payload) ?: return null var remainingInstructionsData = instructionsData val compiledInstructions = mutableListOf() @@ -109,7 +117,7 @@ data class VersionedMessageV0( payload = remainingInstructionsData // Decode Address Table Lookups - val (altCount, lookupData) = ShortVec.decodeLen(payload) + val (altCount, lookupData) = ShortVec.decodeLen(payload) ?: return null var remaining = lookupData val alts = mutableListOf() @@ -127,7 +135,7 @@ data class VersionedMessageV0( } // writable indexes - val (writableIndexLength, writableRemaining) = ShortVec.decodeLen(remaining) + val (writableIndexLength, writableRemaining) = ShortVec.decodeLen(remaining) ?: return null remaining = writableRemaining if (remaining.count() < writableIndexLength) { @@ -138,7 +146,7 @@ data class VersionedMessageV0( remaining = remaining.drop(writableIndexLength) // readonly indexes - val (readonlyIndexLength, readonlyRemaining) = ShortVec.decodeLen(remaining) + val (readonlyIndexLength, readonlyRemaining) = ShortVec.decodeLen(remaining) ?: return null remaining = readonlyRemaining if (remaining.count() < readonlyIndexLength) { diff --git a/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt index c9e5c652c8..124e230943 100644 --- a/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt +++ b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt @@ -2,6 +2,8 @@ package com.getcode.opencode.internal.solana import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull class ShortVecTest { @@ -47,42 +49,42 @@ class ShortVecTest { @Test fun roundtripZero() { val encoded = ShortVec.encodeLen(0) - val (decoded, _) = ShortVec.decodeLen(encoded) + val (decoded, _) = ShortVec.decodeLen(encoded)!! assertEquals(0, decoded) } @Test fun roundtripSmall() { val encoded = ShortVec.encodeLen(42) - val (decoded, _) = ShortVec.decodeLen(encoded) + val (decoded, _) = ShortVec.decodeLen(encoded)!! assertEquals(42, decoded) } @Test fun roundtripBoundary127() { val encoded = ShortVec.encodeLen(127) - val (decoded, _) = ShortVec.decodeLen(encoded) + val (decoded, _) = ShortVec.decodeLen(encoded)!! assertEquals(127, decoded) } @Test fun roundtripBoundary128() { val encoded = ShortVec.encodeLen(128) - val (decoded, _) = ShortVec.decodeLen(encoded) + val (decoded, _) = ShortVec.decodeLen(encoded)!! assertEquals(128, decoded) } @Test fun roundtripBoundary16383() { val encoded = ShortVec.encodeLen(16383) - val (decoded, _) = ShortVec.decodeLen(encoded) + val (decoded, _) = ShortVec.decodeLen(encoded)!! assertEquals(16383, decoded) } @Test fun roundtripBoundary16384() { val encoded = ShortVec.encodeLen(16384) - val (decoded, _) = ShortVec.decodeLen(encoded) + val (decoded, _) = ShortVec.decodeLen(encoded)!! assertEquals(16384, decoded) } @@ -91,11 +93,68 @@ class ShortVecTest { val encoded = ShortVec.encodeLen(5) val extra = listOf(0xA, 0xB, 0xC) val input = encoded + extra - val (value, remaining) = ShortVec.decodeLen(input) + val (value, remaining) = ShortVec.decodeLen(input)!! assertEquals(5, value) assertEquals(extra, remaining) } + @Test + fun roundtripLargeValueNearIntBoundary() { + // 5-byte-encoded values near Int.MAX_VALUE should still round-trip without going + // negative or being rejected by the new `MAX_LEN_BYTES` / sign guard. + val value = Int.MAX_VALUE / 2 + val encoded = ShortVec.encodeLen(value) + val result = ShortVec.decodeLen(encoded) + assertNotNull(result) + assertEquals(value, result.first) + } + + // --- decodeLen malformed-input regression tests --- + // + // These reproduce the crash this change fixes: `decodeLen` used to read `input[offset]` in + // an unbounded loop, throwing `IndexOutOfBoundsException` on empty input or a truncated + // ShortVec whose last byte still has its continuation bit set. On Kotlin/Native that + // exception is an uncaught, fatal trap across the Swift interop boundary rather than a + // catchable error, so the fix is to return `null` instead of throwing. + + @Test + fun decodeLenEmptyInputReturnsNull() { + assertNull(ShortVec.decodeLen(emptyList())) + } + + @Test + fun decodeLenLoneContinuationByteReturnsNull() { + // 0xFF has its continuation bit (0x80) set with no following byte to terminate the + // sequence — the exact one-byte input that used to walk off the end of the list. + assertNull(ShortVec.decodeLen(listOf(0xFF.toByte()))) + } + + @Test + fun decodeLenAllContinuationBytesUpToCapReturnsNull() { + // Five continuation-flagged bytes (MAX_LEN_BYTES worth) with none terminating the + // sequence — still too short to conclude, and also exercises the MAX_LEN_BYTES cap + // itself rather than merely running off the end of a short list. + val input = List(5) { 0xFF.toByte() } + assertNull(ShortVec.decodeLen(input)) + } + + @Test + fun decodeLenTruncatedMultiByteLengthReturnsNull() { + // First byte says "more bytes follow" (continuation bit set) but the input ends right + // there — a valid-looking length prefix that overruns the remaining bytes. + assertNull(ShortVec.decodeLen(listOf(0x80.toByte()))) + } + + @Test + fun decodeLenNegativeResultReturnsNull() { + // Five continuation-carrying bytes (0xFF, 0xFF, 0xFF, 0xFF, then a terminating 0x0F) + // OR together to 0xFFFFFFFF once each byte's 7 payload bits are shifted into place — + // all 32 bits set, i.e. -1 as a signed Int. `decodeLen` must reject this rather than + // silently hand a negative "length" to a caller that treats it as a count. + val input = listOf(0xFF, 0xFF, 0xFF, 0xFF, 0x0F).map { it.toByte() } + assertNull(ShortVec.decodeLen(input)) + } + // --- encode / encodeList --- @Test diff --git a/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/MalformedDecodeTest.kt b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/MalformedDecodeTest.kt new file mode 100644 index 0000000000..bb9057ed83 --- /dev/null +++ b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/MalformedDecodeTest.kt @@ -0,0 +1,195 @@ +package com.getcode.opencode.solana + +import com.getcode.opencode.internal.solana.ShortVec +import com.getcode.opencode.internal.solana.model.MessageAddressLookupTable +import com.getcode.solana.keys.AccountMeta +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.LENGTH_32 +import com.getcode.solana.keys.LENGTH_64 +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Regression tests for the crash fixed in `ShortVec.decodeLen` + * (`libs/solana/encoding/.../internal/solana/ShortVec.kt`): an unbounded `input[offset]` read + * that threw `IndexOutOfBoundsException` on empty input, or on a ShortVec length prefix whose + * last byte still had its continuation bit set. + * + * These exercise every decode entry point that reads a `decodeLen`-prefixed field from + * untrusted bytes — a transaction, a legacy message, a v0 message, and a compiled instruction — + * with malformed input at each stage: empty, a lone unterminated ShortVec byte, and truncation + * part-way through a structurally valid-looking payload. Every case must return `null`, never + * throw. + */ +class MalformedDecodeTest { + + private fun publicKey(seed: Int): PublicKey { + val bytes = ByteArray(32) { if (it == 0) seed.toByte() else 0 } + return PublicKey(bytes.toList()) + } + + private fun hash(seed: Int): Hash { + val bytes = ByteArray(32) { if (it == 0) seed.toByte() else 0 } + return Hash(bytes.toList()) + } + + // --- SolanaTransaction.fromList --- + + @Test + fun solanaTransactionFromListEmptyReturnsNull() { + assertNull(SolanaTransaction.fromList(emptyList())) + } + + @Test + fun solanaTransactionFromListLoneContinuationByteReturnsNull() { + // A single 0xFF as the signature-count ShortVec: continuation bit set, no next byte. + assertNull(SolanaTransaction.fromList(listOf(0xFF.toByte()))) + } + + @Test + fun solanaTransactionFromListTruncatedAtEachStageReturnsNull() { + val payer = publicKey(1) + val instruction = Instruction( + program = publicKey(3), + accounts = listOf(AccountMeta.writable(publicKey(2), signer = true)), + data = listOf(0x01, 0x02, 0x03), + ) + val transaction = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = hash(1), + instructions = listOf(instruction), + ) + val encoded = transaction.encode() + + // Sanity check: the well-formed encoding decodes successfully first, so every failure + // below is attributable to the truncation and not to a construction mistake. + assertNotNull(SolanaTransaction.fromList(encoded)) + + val legacy = (transaction.message as Message.Legacy).message + val sigCount = transaction.signatures.size + val sigSectionLen = ShortVec.encodeLen(sigCount).size + sigCount * LENGTH_64 + val accountCount = legacy.accounts.size + val accountsSectionLen = ShortVec.encodeLen(accountCount).size + accountCount * LENGTH_32 + val headerEnd = sigSectionLen + MessageHeader.length + val accountsEnd = headerEnd + accountsSectionLen + val hashEnd = accountsEnd + LENGTH_32 + + // Truncated mid-signature: one byte short of the full signature block. + assertNull(SolanaTransaction.fromList(encoded.take(sigSectionLen - 1))) + // Truncated mid-header: only 1 of MessageHeader's 3 bytes present. + assertNull(SolanaTransaction.fromList(encoded.take(sigSectionLen + 1))) + // Truncated mid-account-keys: one byte short of a full PublicKey list. + assertNull(SolanaTransaction.fromList(encoded.take(accountsEnd - 1))) + // Truncated mid-recent-blockhash: one byte short of the 32-byte hash. + assertNull(SolanaTransaction.fromList(encoded.take(hashEnd - 1))) + // Truncated mid-instruction: the last instruction's final data byte missing. + assertNull(SolanaTransaction.fromList(encoded.dropLast(1))) + } + + // --- LegacyMessage.newInstance --- + + @Test + fun legacyMessageNewInstanceEmptyReturnsNull() { + assertNull(LegacyMessage.newInstance(emptyList())) + } + + @Test + fun legacyMessageNewInstanceLoneContinuationByteReturnsNull() { + assertNull(LegacyMessage.newInstance(listOf(0xFF.toByte()))) + } + + @Test + fun legacyMessageNewInstanceTruncatedReturnsNull() { + val accounts = listOf( + AccountMeta.payer(publicKey(1)), + AccountMeta.writable(publicKey(2)), + ) + val instruction = Instruction( + program = publicKey(3), + accounts = listOf(AccountMeta.writable(publicKey(2))), + data = listOf(0x0A, 0x0B), + ) + val message = LegacyMessage.newInstance( + accounts = accounts + AccountMeta.program(publicKey(3)), + recentBlockhash = hash(9), + instructions = listOf(instruction), + ) + val encoded = message.encode().toList() + + assertNotNull(LegacyMessage.newInstance(encoded)) + + // Truncated before the header is fully present. + assertNull(LegacyMessage.newInstance(encoded.take(1))) + assertNull(LegacyMessage.newInstance(encoded.take(MessageHeader.length - 1))) + // Truncated mid-instruction. + assertNull(LegacyMessage.newInstance(encoded.dropLast(1))) + } + + // --- VersionedMessageV0.newInstance --- + + @Test + fun versionedMessageV0NewInstanceEmptyReturnsNull() { + assertNull(VersionedMessageV0.newInstance(emptyList())) + } + + @Test + fun versionedMessageV0NewInstanceLoneContinuationByteReturnsNull() { + // Valid version-prefix byte (0x80), followed by nothing — decodeLen for the account-key + // count is never even reached; the header-length guard fires first. + assertNull(VersionedMessageV0.newInstance(listOf(0x80.toByte()))) + } + + @Test + fun versionedMessageV0NewInstanceTruncatedReturnsNull() { + val v0 = VersionedMessageV0( + header = MessageHeader(requiredSignatures = 1, readOnlySigners = 0, readOnly = 1), + staticAccountKeys = listOf(publicKey(1), publicKey(2)), + recentBlockhash = hash(1), + instructions = listOf(CompiledInstruction(0, listOf(1), listOf(0x0A, 0x0B))), + addressLookupTables = listOf( + MessageAddressLookupTable(publicKey(50), listOf(0), listOf(1)) + ), + ) + val encoded = v0.encode() + + assertNotNull(VersionedMessageV0.newInstance(encoded)) + + // Truncated right after the version byte: header entirely missing. + assertNull(VersionedMessageV0.newInstance(encoded.take(1))) + // Truncated mid-header: only 1 of 3 header bytes present after the version byte. + assertNull(VersionedMessageV0.newInstance(encoded.take(1 + 1))) + // Truncated mid-static-account-keys. This is also the regression case for a bug found + // alongside the crash fix: this path used to decode static keys with stdlib `chunked()` + + // `runCatching { PublicKey(chunk) }.getOrNull()`, which never throws (PublicKey accepts + // any-length input), so a truncated key list was silently accepted instead of failing. + // It now uses the module's bounds-checked `DataSlice.chunk`, so this must return null. + val versionAndHeaderLen = 1 + MessageHeader.length + val staticKeysSectionLen = ShortVec.encodeLen(2).size + 2 * LENGTH_32 + assertNull( + VersionedMessageV0.newInstance( + encoded.take(versionAndHeaderLen + staticKeysSectionLen - 1) + ) + ) + // Truncated mid-address-lookup-table (the final field): last readonly-index byte missing. + assertNull(VersionedMessageV0.newInstance(encoded.dropLast(1))) + } + + // --- CompiledInstruction.fromList --- + + @Test + fun compiledInstructionFromListTruncatedAccountIndexesReturnsNull() { + // programIndex=0, ShortVec accountCount=2, but only one index byte follows. + val bytes = listOf(0.toByte(), 2.toByte(), 5.toByte()) + assertNull(CompiledInstruction.fromList(bytes)) + } + + @Test + fun compiledInstructionFromListTruncatedDataReturnsNull() { + // programIndex=0, accountCount=1 with index byte 0, opaque data length=5 but only 2 + // data bytes actually follow. + val bytes = listOf(0.toByte(), 1.toByte(), 0.toByte(), 5.toByte(), 0xAA.toByte(), 0xBB.toByte()) + assertNull(CompiledInstruction.fromList(bytes)) + } +} From 5bf0fdfe64c5ed59f17afe61304c1426ebc7107c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 12 Sep 2026 09:24:26 -0400 Subject: [PATCH 20/26] refactor(shared-core-kit): funnel SharedCore qualification through Kotlin* aliases SolanaMessage.swift and SolanaTransaction.swift spelled out SharedCore.X at every Kotlin type reference, alongside their own Shared*-prefixed Swift facade types (SharedSolanaMessage, SharedSolanaInstruction, ...). The two prefixes read as near-duplicates at a glance, which makes it easy to misread a Kotlin-side reference as the Swift facade type. Adds KotlinTypes.swift, declaring an internal typealias Kotlin* for each SharedCore type these two files touch (AccountMeta, AddressLookupTable, CompiledInstruction, Instruction, Key32, LegacyMessage, Message, MessageAddressLookupTable, MessageCompanion, MessageHeader, MessageLegacy, MessageVersionedV0, PublicKey, Signature, SolanaTransaction, VersionedMessageV0), then rewrites both files against those aliases. The aliases stay internal: nothing outside SharedCoreKit should reference a raw Kotlin type, since the Shared* facade exists precisely so callers never have to. KotlinByteList+Bridge.swift needed no change: it references only KotlinByte, already unqualified. BondingCurve.swift, Base58.swift, and SharedCoreInfo.swift are untouched. Package.swift is unchanged. Also drops the guard !data.isEmpty in SharedSolanaTransaction.init(data:), now that SolanaTransaction.fromList returns null for empty and truncated input instead of throwing (see the ShortVec.decodeLen fix). The existing "SharedSolanaTransaction rejects malformed input" test already covers Data() and continues to pass with the guard gone, now exercising the full path down into Kotlin rather than short-circuiting on the Swift side. Verified with swift test against a locally reassembled SharedCore.xcframework (FLIPCASH_SHARED_CORE_LOCAL): all 40 existing tests pass unedited. --- .../Sources/SharedCoreKit/KotlinTypes.swift | 28 ++++++++ .../Sources/SharedCoreKit/SolanaMessage.swift | 72 +++++++++---------- .../SharedCoreKit/SolanaTransaction.swift | 50 ++++++------- 3 files changed, 86 insertions(+), 64 deletions(-) create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/KotlinTypes.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinTypes.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinTypes.swift new file mode 100644 index 0000000000..f0d0b2bbbe --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinTypes.swift @@ -0,0 +1,28 @@ +import SharedCore + +/// `Kotlin`-prefixed aliases for the `SharedCore` Solana message/instruction/account type +/// hierarchy that `SolanaMessage.swift` and `SolanaTransaction.swift` bridge. Both files already +/// define their own `Shared*`-prefixed public types (`SharedSolanaMessage`, +/// `SharedSolanaInstruction`, ...); qualifying every Kotlin-side reference as `SharedCore.X` reads, +/// at a glance, as if it were one of those — the aliases here exist purely so call sites can say +/// `KotlinMessage` and have it unambiguously mean "the Kotlin type", not "the Swift facade type". +/// +/// `internal`, not `public`: these aliases are a readability aid for this package's own bridging +/// code. Nothing outside `SharedCoreKit` should see a raw Kotlin type — the whole point of the +/// `Shared*` facade is that callers never touch `SharedCore` directly. +internal typealias KotlinAccountMeta = SharedCore.AccountMeta +internal typealias KotlinAddressLookupTable = SharedCore.AddressLookupTable +internal typealias KotlinCompiledInstruction = SharedCore.CompiledInstruction +internal typealias KotlinInstruction = SharedCore.Instruction +internal typealias KotlinKey32 = SharedCore.Key32 +internal typealias KotlinLegacyMessage = SharedCore.LegacyMessage +internal typealias KotlinMessage = SharedCore.Message +internal typealias KotlinMessageAddressLookupTable = SharedCore.MessageAddressLookupTable +internal typealias KotlinMessageCompanion = SharedCore.MessageCompanion +internal typealias KotlinMessageHeader = SharedCore.MessageHeader +internal typealias KotlinMessageLegacy = SharedCore.MessageLegacy +internal typealias KotlinMessageVersionedV0 = SharedCore.MessageVersionedV0 +internal typealias KotlinPublicKey = SharedCore.PublicKey +internal typealias KotlinSignature = SharedCore.Signature +internal typealias KotlinSolanaTransaction = SharedCore.SolanaTransaction +internal typealias KotlinVersionedMessageV0 = SharedCore.VersionedMessageV0 diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift index 2cb005c7ea..ce6e4badbb 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift @@ -35,7 +35,7 @@ public struct SharedSolanaMessageHeader: Equatable, Sendable { } extension SharedSolanaMessageHeader { - init(_ header: SharedCore.MessageHeader) { + init(_ header: KotlinMessageHeader) { self.init( requiredSignatures: Int(header.requiredSignatures), readOnlySigners: Int(header.readOnlySigners), @@ -43,8 +43,8 @@ extension SharedSolanaMessageHeader { ) } - var kotlin: SharedCore.MessageHeader { - SharedCore.MessageHeader( + var kotlin: KotlinMessageHeader { + KotlinMessageHeader( requiredSignatures: Int32(requiredSignatures), readOnlySigners: Int32(readOnlySigners), readOnly: Int32(readOnly) @@ -97,7 +97,7 @@ public struct SharedSolanaAccountMeta: Equatable, Sendable { } extension SharedSolanaAccountMeta { - init(_ meta: SharedCore.AccountMeta) { + init(_ meta: KotlinAccountMeta) { self.init( publicKey: Data(meta.publicKey.byteArray), isSigner: meta.isSigner, @@ -107,9 +107,9 @@ extension SharedSolanaAccountMeta { ) } - var kotlin: SharedCore.AccountMeta { - SharedCore.AccountMeta( - publicKey: SharedCore.PublicKey(bytes: publicKey.kotlinByteList), + var kotlin: KotlinAccountMeta { + KotlinAccountMeta( + publicKey: KotlinPublicKey(bytes: publicKey.kotlinByteList), isSigner: isSigner, isWritable: isWritable, isPayer: isPayer, @@ -135,13 +135,13 @@ public struct SharedSolanaInstruction: Equatable, Sendable { } public func compile(messageAccounts: [Data]) -> SharedSolanaCompiledInstruction { - let accounts = messageAccounts.map { SharedCore.PublicKey(bytes: $0.kotlinByteList) } + let accounts = messageAccounts.map { KotlinPublicKey(bytes: $0.kotlinByteList) } return SharedSolanaCompiledInstruction(kotlin.compile(messageAccounts: accounts)) } } extension SharedSolanaInstruction { - init(_ instruction: SharedCore.Instruction) { + init(_ instruction: KotlinInstruction) { self.init( program: Data(instruction.program.byteArray), accounts: instruction.accounts.map(SharedSolanaAccountMeta.init), @@ -149,9 +149,9 @@ extension SharedSolanaInstruction { ) } - var kotlin: SharedCore.Instruction { - SharedCore.Instruction( - program: SharedCore.PublicKey(bytes: program.kotlinByteList), + var kotlin: KotlinInstruction { + KotlinInstruction( + program: KotlinPublicKey(bytes: program.kotlinByteList), accounts: accounts.map { $0.kotlin }, data: data.kotlinByteList ) @@ -175,7 +175,7 @@ public struct SharedSolanaCompiledInstruction: Equatable, Sendable { /// Parses a single wire-format compiled instruction — `programIndex(1) + shortVec(accountIndexes) + /// shortVec(data)` — with no enclosing message. `nil` if `data` is short or malformed. public init?(data: Data) { - guard let result = SharedCore.CompiledInstruction.companion.fromList(list: data.kotlinByteList) else { return nil } + guard let result = KotlinCompiledInstruction.companion.fromList(list: data.kotlinByteList) else { return nil } self.init(result) } @@ -192,7 +192,7 @@ public struct SharedSolanaCompiledInstruction: Equatable, Sendable { } extension SharedSolanaCompiledInstruction { - init(_ instruction: SharedCore.CompiledInstruction) { + init(_ instruction: KotlinCompiledInstruction) { self.init( programIndex: UInt8(bitPattern: instruction.programIndex), accountIndexes: [UInt8](kotlinByteList: instruction.accountIndexes), @@ -200,8 +200,8 @@ extension SharedSolanaCompiledInstruction { ) } - var kotlin: SharedCore.CompiledInstruction { - SharedCore.CompiledInstruction( + var kotlin: KotlinCompiledInstruction { + KotlinCompiledInstruction( programIndex: Int8(bitPattern: programIndex), accountIndexes: accountIndexes.kotlinByteList, data: data.kotlinByteList @@ -232,7 +232,7 @@ public struct SharedSolanaMessageAddressTableLookup: Equatable, Sendable { } extension SharedSolanaMessageAddressTableLookup { - init(_ lookup: SharedCore.MessageAddressLookupTable) { + init(_ lookup: KotlinMessageAddressLookupTable) { self.init( publicKey: Data(lookup.publicKey.byteArray), writableIndexes: [UInt8](kotlinByteList: lookup.writableIndexes), @@ -240,9 +240,9 @@ extension SharedSolanaMessageAddressTableLookup { ) } - var kotlin: SharedCore.MessageAddressLookupTable { - SharedCore.MessageAddressLookupTable( - publicKey: SharedCore.PublicKey(bytes: publicKey.kotlinByteList), + var kotlin: KotlinMessageAddressLookupTable { + KotlinMessageAddressLookupTable( + publicKey: KotlinPublicKey(bytes: publicKey.kotlinByteList), writableIndexes: writableIndexes.kotlinByteList, readonlyIndexes: readonlyIndexes.kotlinByteList ) @@ -265,10 +265,10 @@ public struct SharedSolanaAddressLookupTable: Equatable, Sendable { } extension SharedSolanaAddressLookupTable { - var kotlin: SharedCore.AddressLookupTable { - SharedCore.AddressLookupTable( - publicKey: SharedCore.PublicKey(bytes: publicKey.kotlinByteList), - addresses: addresses.map { SharedCore.PublicKey(bytes: $0.kotlinByteList) } + var kotlin: KotlinAddressLookupTable { + KotlinAddressLookupTable( + publicKey: KotlinPublicKey(bytes: publicKey.kotlinByteList), + addresses: addresses.map { KotlinPublicKey(bytes: $0.kotlinByteList) } ) } } @@ -294,7 +294,7 @@ public struct SharedSolanaLegacyMessage: Equatable, Sendable { } extension SharedSolanaLegacyMessage { - init(_ message: SharedCore.LegacyMessage) { + init(_ message: KotlinLegacyMessage) { self.init( header: SharedSolanaMessageHeader(message.header), accounts: message.accounts.map(SharedSolanaAccountMeta.init), @@ -303,11 +303,11 @@ extension SharedSolanaLegacyMessage { ) } - var kotlin: SharedCore.LegacyMessage { - SharedCore.LegacyMessage( + var kotlin: KotlinLegacyMessage { + KotlinLegacyMessage( header: header.kotlin, accounts: accounts.map { $0.kotlin }, - recentBlockhash: SharedCore.Key32(bytes: recentBlockhash.kotlinByteList), + recentBlockhash: KotlinKey32(bytes: recentBlockhash.kotlinByteList), instructions: instructions.map { $0.kotlin } ) } @@ -342,7 +342,7 @@ public struct SharedSolanaVersionedMessageV0: Equatable, Sendable { } extension SharedSolanaVersionedMessageV0 { - init(_ message: SharedCore.VersionedMessageV0) { + init(_ message: KotlinVersionedMessageV0) { self.init( header: SharedSolanaMessageHeader(message.header), staticAccountKeys: message.staticAccountKeys.map { Data($0.byteArray) }, @@ -352,11 +352,11 @@ extension SharedSolanaVersionedMessageV0 { ) } - var kotlin: SharedCore.VersionedMessageV0 { - SharedCore.VersionedMessageV0( + var kotlin: KotlinVersionedMessageV0 { + KotlinVersionedMessageV0( header: header.kotlin, - staticAccountKeys: staticAccountKeys.map { SharedCore.PublicKey(bytes: $0.kotlinByteList) }, - recentBlockhash: SharedCore.Key32(bytes: recentBlockhash.kotlinByteList), + staticAccountKeys: staticAccountKeys.map { KotlinPublicKey(bytes: $0.kotlinByteList) }, + recentBlockhash: KotlinKey32(bytes: recentBlockhash.kotlinByteList), instructions: instructions.map { $0.kotlin }, addressLookupTables: addressLookupTables.map { $0.kotlin } ) @@ -452,11 +452,11 @@ public enum SharedSolanaMessage: Equatable, Sendable { /// Parses `data` as a message — legacy or v0, decided by the version-prefix byte. `nil` if /// `data` matches neither wire format. public init?(data: Data) { - guard let result = SharedCore.MessageCompanion.shared.doNewInstance(data: data.kotlinByteList) else { return nil } + guard let result = KotlinMessageCompanion.shared.doNewInstance(data: data.kotlinByteList) else { return nil } switch result { - case let legacy as SharedCore.MessageLegacy: + case let legacy as KotlinMessageLegacy: self = .legacy(SharedSolanaLegacyMessage(legacy.message)) - case let v0 as SharedCore.MessageVersionedV0: + case let v0 as KotlinMessageVersionedV0: self = .versionedV0(SharedSolanaVersionedMessageV0(v0.message)) default: return nil diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift index 89c0bb715f..90a708f21c 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift @@ -32,19 +32,13 @@ public struct SharedSolanaTransaction: Equatable, Sendable { } /// Parses `data` as a full transaction (signatures + message). `nil` if `data` does not parse - /// as either message version, or its signature count doesn't match the message header. - /// - /// Guards `data.isEmpty` itself rather than forwarding it to Kotlin: `SolanaTransaction.fromList` - /// reads its leading ShortVec length byte with no bounds check - /// (`ShortVec.decodeLen`, `libs/solana/encoding/.../internal/solana/ShortVec.kt`), so an empty - /// list crashes the process (an uncaught `IndexOutOfBoundsException`, not a Swift `nil`) instead - /// of failing the way this initializer's signature promises. This one-line guard only covers the - /// fully-empty case; a truncated ShortVec whose last byte still has its continuation bit set - /// (e.g. a lone `0xFF`) hits the same unbounded read one byte later and is not guarded here — see - /// the facade's final report for why a complete fix belongs in `ShortVec.decodeLen`, not here. + /// as either message version, its signature count doesn't match the message header, or `data` + /// is empty or otherwise truncated — `SolanaTransaction.fromList` and the `ShortVec.decodeLen` + /// it's built on + /// (`libs/solana/encoding/.../internal/solana/ShortVec.kt`) return `null` for all of those + /// instead of throwing, so no guard is needed on the Swift side. public init?(data: Data) { - guard !data.isEmpty else { return nil } - guard let result = SharedCore.SolanaTransaction.companion.fromList(list: data.kotlinByteList) else { return nil } + guard let result = KotlinSolanaTransaction.companion.fromList(list: data.kotlinByteList) else { return nil } self.init(result) } @@ -57,9 +51,9 @@ public struct SharedSolanaTransaction: Equatable, Sendable { } public init(payer: Data, recentBlockhash: Data?, instructions: [SharedSolanaInstruction]) { - let result = SharedCore.SolanaTransaction.companion.doNewInstance( - payer: SharedCore.PublicKey(bytes: payer.kotlinByteList), - recentBlockhash: recentBlockhash.map { SharedCore.Key32(bytes: $0.kotlinByteList) }, + let result = KotlinSolanaTransaction.companion.doNewInstance( + payer: KotlinPublicKey(bytes: payer.kotlinByteList), + recentBlockhash: recentBlockhash.map { KotlinKey32(bytes: $0.kotlinByteList) }, instructions: instructions.map { $0.kotlin } ) self.init(result) @@ -73,9 +67,9 @@ public struct SharedSolanaTransaction: Equatable, Sendable { } public init(payer: Data, recentBlockhash: Data?, addressLookupTables: [SharedSolanaAddressLookupTable], instructions: [SharedSolanaInstruction]) { - let result = SharedCore.SolanaTransaction.companion.doNewV0Instance( - payer: SharedCore.PublicKey(bytes: payer.kotlinByteList), - recentBlockhash: recentBlockhash.map { SharedCore.Key32(bytes: $0.kotlinByteList) }, + let result = KotlinSolanaTransaction.companion.doNewV0Instance( + payer: KotlinPublicKey(bytes: payer.kotlinByteList), + recentBlockhash: recentBlockhash.map { KotlinKey32(bytes: $0.kotlinByteList) }, addressLookupTables: addressLookupTables.map { $0.kotlin }, instructions: instructions.map { $0.kotlin } ) @@ -84,15 +78,15 @@ public struct SharedSolanaTransaction: Equatable, Sendable { } extension SharedSolanaTransaction { - init(_ transaction: SharedCore.SolanaTransaction) { + init(_ transaction: KotlinSolanaTransaction) { let message: SharedSolanaMessage switch transaction.message { - case let legacy as SharedCore.MessageLegacy: + case let legacy as KotlinMessageLegacy: message = .legacy(SharedSolanaLegacyMessage(legacy.message)) - case let v0 as SharedCore.MessageVersionedV0: + case let v0 as KotlinMessageVersionedV0: message = .versionedV0(SharedSolanaVersionedMessageV0(v0.message)) default: - preconditionFailure("SharedCore.Message has only Legacy and VersionedV0 cases") + preconditionFailure("KotlinMessage has only Legacy and VersionedV0 cases") } self.init( message: message, @@ -100,17 +94,17 @@ extension SharedSolanaTransaction { ) } - var kotlin: SharedCore.SolanaTransaction { - let kotlinMessage: SharedCore.Message + var kotlin: KotlinSolanaTransaction { + let kotlinMessage: KotlinMessage switch message { case .legacy(let m): - kotlinMessage = SharedCore.MessageLegacy(message: m.kotlin) + kotlinMessage = KotlinMessageLegacy(message: m.kotlin) case .versionedV0(let m): - kotlinMessage = SharedCore.MessageVersionedV0(message: m.kotlin) + kotlinMessage = KotlinMessageVersionedV0(message: m.kotlin) } - return SharedCore.SolanaTransaction( + return KotlinSolanaTransaction( message: kotlinMessage, - signatures: signatures.map { SharedCore.Signature(bytes: $0.kotlinByteList) } + signatures: signatures.map { KotlinSignature(bytes: $0.kotlinByteList) } ) } } From b09f84d67fdf55247c95e27cdfb503db7556caf3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 12 Sep 2026 11:34:14 -0400 Subject: [PATCH 21/26] fix(solana): return null from Instruction.compile when an account is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instruction.compile used indexOfFirst to resolve each account to its index in messageAccounts, but indexOfFirst returns -1 on a miss and (-1).toByte() is 0xFF — a structurally valid but wrong index, so a missing account silently compiled into a corrupt instruction instead of failing. The three internal callers can't hit this (each passes an account list built from the same instructions being compiled), but compile is exported to iOS through SharedCoreKit, where a caller-supplied account list can miss. compile now returns CompiledInstruction? and returns null on the first missing program or account. The three callers (LegacyMessage.encode, Message.instructions, SolanaTransaction.newV0Instance) keep their existing non-nullable signatures — changing them would cascade into transaction building and intent construction in :services:opencode and :apps:flipcash, which this slice of the KMP encoding work doesn't touch — and convert a null back to an error() at the point each one's own invariant guarantees compile can't miss. SharedSolanaInstruction.compile(messageAccounts:) in the Swift facade is failable to match, and its one internal caller (SharedSolanaMessage.instructions) mirrors the same error()-on-proven-unreachable-null shape. --- .../Sources/SharedCoreKit/SolanaMessage.swift | 19 ++++++++-- .../getcode/opencode/solana/Instruction.kt | 17 ++++++++- .../getcode/opencode/solana/LegacyMessage.kt | 8 +++- .../com/getcode/opencode/solana/Message.kt | 6 +++ .../opencode/solana/SolanaTransaction.kt | 7 +++- .../solana/InstructionIntegrationTest.kt | 37 +++++++++++++++++++ 6 files changed, 87 insertions(+), 7 deletions(-) diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift index ce6e4badbb..a65052f071 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift @@ -134,9 +134,13 @@ public struct SharedSolanaInstruction: Equatable, Sendable { self.data = data } - public func compile(messageAccounts: [Data]) -> SharedSolanaCompiledInstruction { + /// Compiles this instruction against `messageAccounts` by replacing `program` and each + /// account's key with its index into `messageAccounts`. `nil` if `program` or any of + /// `accounts` is not present in `messageAccounts`. + public func compile(messageAccounts: [Data]) -> SharedSolanaCompiledInstruction? { let accounts = messageAccounts.map { KotlinPublicKey(bytes: $0.kotlinByteList) } - return SharedSolanaCompiledInstruction(kotlin.compile(messageAccounts: accounts)) + guard let result = kotlin.compile(messageAccounts: accounts) else { return nil } + return SharedSolanaCompiledInstruction(result) } } @@ -427,8 +431,17 @@ public enum SharedSolanaMessage: Equatable, Sendable { public var instructions: [SharedSolanaCompiledInstruction] { switch self { case .legacy(let message): + // `accounts` above is this message's own full account list, and `message.instructions` + // are this same message's instructions, so every program/account an instruction + // references is always present in `accounts` — `compile` returning `nil` here would + // mean this message's own invariant was violated elsewhere. let accounts = message.accounts.map(\.publicKey) - return message.instructions.map { $0.compile(messageAccounts: accounts) } + return message.instructions.map { instruction in + guard let compiled = instruction.compile(messageAccounts: accounts) else { + fatalError("instruction references an account missing from this message") + } + return compiled + } case .versionedV0(let message): return message.instructions } diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt index caf678abd6..3ae8dfd74d 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt @@ -14,10 +14,23 @@ data class Instruction( val accounts: List, val data: List, ) { - fun compile(messageAccounts: List): CompiledInstruction { + /** + * Compiles this instruction against [messageAccounts] by replacing [program] and each + * account's [PublicKey] with its index into [messageAccounts]. + * + * @return the compiled instruction, or `null` if [program] or any [accounts] entry is not + * present in [messageAccounts]. `indexOfFirst` returns `-1` on a miss, and `(-1).toByte()` + * is `0xFF` — a structurally valid but wrong index — so a miss is reported as `null` instead + * of silently compiling a corrupt instruction. + */ + fun compile(messageAccounts: List): CompiledInstruction? { val programIndex = messageAccounts.indexOfFirst { it == program } + if (programIndex < 0) return null + val accountIndexes = accounts.map { account -> - messageAccounts.indexOfFirst { it == account.publicKey }.toByte() + val accountIndex = messageAccounts.indexOfFirst { it == account.publicKey } + if (accountIndex < 0) return null + accountIndex.toByte() } return CompiledInstruction( diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt index 2b6909eb29..a4046d2b15 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt @@ -33,7 +33,13 @@ data class LegacyMessage( val data = mutableListOf() val accounts = accounts.map { it.publicKey } - val instructions = instructions.map { it.compile(accounts) } + // `accounts` above is this message's own full account list, built from the same + // `instructions` being compiled below (see `newInstance`), so every program/account an + // instruction references is always present in it — `compile` returning `null` here would + // mean this message's own invariant was violated elsewhere. + val instructions = instructions.map { + it.compile(accounts) ?: error("instruction references an account missing from this message") + } data.addAll(header.encode().toList()) data.addAll(ShortVec.encodeList(accounts.map { it.bytes })) diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Message.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Message.kt index 89d21634e7..f451365754 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Message.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Message.kt @@ -67,8 +67,14 @@ sealed interface Message { val instructions: List get() = when (this) { + // `accountKeys` above is this message's own full account list (see + // `LegacyMessage.newInstance`), built from the same `message.instructions` being + // compiled here, so every program/account an instruction references is always + // present in it — `compile` returning `null` here would mean this message's own + // invariant was violated elsewhere. is Legacy -> message.instructions.map { instruction -> instruction.compile(accountKeys) + ?: error("instruction references an account missing from this message") } is VersionedV0 -> message.instructions } diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt index 7debb9c2cf..3efaece982 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt @@ -273,9 +273,14 @@ data class SolanaTransaction(val message: Message, val signatures: List instruction.compile(allAccounts) + ?: error("instruction references an account missing from allAccounts") } // Create the V0 message diff --git a/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt index 473330c579..1f02ddb6f5 100644 --- a/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt +++ b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt @@ -99,6 +99,7 @@ class InstructionIntegrationTest { val messageAccounts = listOf(acc0, acc1, program) val compiled = instruction.compile(messageAccounts) + assertNotNull(compiled) assertEquals(2.toByte(), compiled.programIndex) // program at index 2 assertEquals(listOf(0, 1), compiled.accountIndexes) // acc0=0, acc1=1 @@ -118,6 +119,42 @@ class InstructionIntegrationTest { assertEquals(instruction.data, decompiled.data) } + @Test + fun compileWithProgramMissingFromMessageAccountsReturnsNull() { + val program = publicKey(10) + val acc0 = publicKey(1) + + val instruction = Instruction( + program = program, + accounts = listOf(AccountMeta.writable(acc0, signer = true)), + data = listOf(0x01), + ) + + // `program` is not present in `messageAccounts` — only its accounts are. + val messageAccounts = listOf(acc0) + assertNull(instruction.compile(messageAccounts)) + } + + @Test + fun compileWithAccountMissingFromMessageAccountsReturnsNull() { + val program = publicKey(10) + val acc0 = publicKey(1) + val acc1 = publicKey(2) + + val instruction = Instruction( + program = program, + accounts = listOf( + AccountMeta.writable(acc0, signer = true), + AccountMeta.readonly(acc1), + ), + data = listOf(0x01), + ) + + // `acc1` is not present in `messageAccounts`. + val messageAccounts = listOf(program, acc0) + assertNull(instruction.compile(messageAccounts)) + } + @Test fun decompileWithInsufficientAccountsReturnsNull() { val compiled = CompiledInstruction( From 14c726118401b2a618796cd8b469b7211a9dedb4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 12 Sep 2026 11:43:23 -0400 Subject: [PATCH 22/26] fix(shared-core-kit): reject a legacy message whose instructions reference unknown accounts SharedSolanaLegacyMessage's public memberwise init took accounts and instructions independently, so a caller could build a message whose instruction referenced an account absent from accounts. Nothing enforced that invariant, and both encode() and SharedSolanaMessage.instructions assumed it held, calling into Kotlin's LegacyMessage.encode()/Message.instructions, which trap with an uncaught IllegalStateException across the Kotlin/Native boundary when it doesn't. Make the public init failable: it now validates every instruction's program and account keys against accounts and returns nil otherwise. The internal init(_ message: KotlinLegacyMessage) stays non-failable, since Kotlin's own LegacyMessage.newInstance already guarantees the invariant for decoded values. With construction validated, SharedSolanaMessage.instructions no longer needs its fatalError guard. Audited the rest of the facade's public inits for the same hole: SharedSolanaVersionedMessageV0 stores already-compiled, index-based instructions and never calls compile(), and SharedSolanaTransaction's payer-based builder inits derive their account list from instructions inside Kotlin's doNewInstance/doNewV0Instance rather than accepting one from the caller, so neither can reach an inconsistent state this way. --- .../Sources/SharedCoreKit/SolanaMessage.swift | 51 ++++++++++---- .../SolanaMessageTests.swift | 67 +++++++++++++++++++ .../getcode/opencode/solana/LegacyMessage.kt | 12 ++-- .../com/getcode/opencode/solana/Message.kt | 11 +-- .../opencode/solana/SolanaTransaction.kt | 10 ++- 5 files changed, 127 insertions(+), 24 deletions(-) diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift index a65052f071..fbf96e4b59 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift @@ -285,7 +285,28 @@ public struct SharedSolanaLegacyMessage: Equatable, Sendable { public var recentBlockhash: Data public var instructions: [SharedSolanaInstruction] - public init(header: SharedSolanaMessageHeader, accounts: [SharedSolanaAccountMeta], recentBlockhash: Data, instructions: [SharedSolanaInstruction]) { + /// Fails if any instruction's `program`, or any of its accounts' `publicKey`, is absent from + /// `accounts`. That's the one caller-constructible hole behind the `error(...)` traps in + /// Kotlin's `LegacyMessage.encode()` (`LegacyMessage.kt`) and `Message.instructions` + /// (`Message.kt`): both assume every instruction only references accounts already in the + /// message's own account list, which `newInstance`-built messages guarantee but a + /// caller-supplied `accounts`/`instructions` pair does not. Validating here — the only public, + /// caller-reachable constructor for this type — makes that assumption actually hold for every + /// value this facade can produce, so `encode()` and `SharedSolanaMessage.instructions` below + /// can stay total instead of trapping. + /// + /// Decoded messages skip this check via `init(_ message: KotlinLegacyMessage)` below: Kotlin's + /// own `LegacyMessage.newInstance` already guarantees the invariant there, so re-validating on + /// every decode would be redundant work with no way to ever fail. + public init?(header: SharedSolanaMessageHeader, accounts: [SharedSolanaAccountMeta], recentBlockhash: Data, instructions: [SharedSolanaInstruction]) { + let accountKeys = Set(accounts.map(\.publicKey)) + for instruction in instructions { + guard accountKeys.contains(instruction.program) else { return nil } + for account in instruction.accounts { + guard accountKeys.contains(account.publicKey) else { return nil } + } + } + self.header = header self.accounts = accounts self.recentBlockhash = recentBlockhash @@ -298,13 +319,16 @@ public struct SharedSolanaLegacyMessage: Equatable, Sendable { } extension SharedSolanaLegacyMessage { + // Assigns stored properties directly rather than delegating to the public, validating + // `init?(header:accounts:recentBlockhash:instructions:)`: that initializer is failable, and a + // non-failable initializer cannot delegate to one written `init?`. Delegating is unnecessary + // here anyway — Kotlin's own `LegacyMessage.newInstance` already guarantees this invariant for + // any `KotlinLegacyMessage` that exists, so this path has no validation to perform. init(_ message: KotlinLegacyMessage) { - self.init( - header: SharedSolanaMessageHeader(message.header), - accounts: message.accounts.map(SharedSolanaAccountMeta.init), - recentBlockhash: Data(message.recentBlockhash.byteArray), - instructions: message.instructions.map(SharedSolanaInstruction.init) - ) + self.header = SharedSolanaMessageHeader(message.header) + self.accounts = message.accounts.map(SharedSolanaAccountMeta.init) + self.recentBlockhash = Data(message.recentBlockhash.byteArray) + self.instructions = message.instructions.map(SharedSolanaInstruction.init) } var kotlin: KotlinLegacyMessage { @@ -433,14 +457,15 @@ public enum SharedSolanaMessage: Equatable, Sendable { case .legacy(let message): // `accounts` above is this message's own full account list, and `message.instructions` // are this same message's instructions, so every program/account an instruction - // references is always present in `accounts` — `compile` returning `nil` here would - // mean this message's own invariant was violated elsewhere. + // references is always present in `accounts` — `SharedSolanaLegacyMessage`'s only + // public constructor (`init?(header:accounts:recentBlockhash:instructions:)`) rejects + // any value where that would not hold, and the internal `init(_ message: + // KotlinLegacyMessage)` only wraps values Kotlin's own `LegacyMessage.newInstance` + // already built consistently. So `compile` returning `nil` here is unreachable; force- + // unwrap rather than thread an `Optional` through a getter that can never actually fail. let accounts = message.accounts.map(\.publicKey) return message.instructions.map { instruction in - guard let compiled = instruction.compile(messageAccounts: accounts) else { - fatalError("instruction references an account missing from this message") - } - return compiled + instruction.compile(messageAccounts: accounts)! } case .versionedV0(let message): return message.instructions diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift index 6d210cf934..007641259a 100644 --- a/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift @@ -173,4 +173,71 @@ struct SolanaMessageTests { #expect(SharedSolanaTransaction(data: Data()) == nil) #expect(SharedSolanaTransaction(data: Data([0x00])) == nil) } + + // MARK: - SharedSolanaLegacyMessage construction validation + // + // Regression coverage for the caller-constructible hole behind the `SharedSolanaLegacyMessage` + // trap: nothing enforced that an instruction's `program`/accounts were present in `accounts`, + // so a caller-built message with a dangling account reference would trap uncatchably inside + // `encode()` (via `LegacyMessage.encode()`'s `error(...)`) once it crossed into Kotlin. These + // vectors are the ones a `.legacy` message reaches that trap through — `encode()` and + // `SharedSolanaMessage.instructions` (see `SolanaMessage.swift`) — so a validating initializer + // that returns `nil` here is what keeps the facade from ever handing Kotlin an inconsistent + // value in the first place. + + @Test("SharedSolanaLegacyMessage(header:accounts:recentBlockhash:instructions:) rejects an instruction account missing from accounts") + func rejectsInstructionAccountMissingFromAccounts() { + let k1 = Data(repeating: 1, count: 32) + let k2 = Data(repeating: 2, count: 32) + let missing = Data(repeating: 9, count: 32) // not in accounts + + let message = SharedSolanaLegacyMessage( + header: SharedSolanaMessageHeader(requiredSignatures: 1, readOnlySigners: 0, readOnly: 1), + accounts: [.payer(publicKey: k1), .program(publicKey: k2)], + recentBlockhash: Data(repeating: 99, count: 32), + instructions: [ + SharedSolanaInstruction(program: k2, accounts: [.readonly(publicKey: missing)], data: Data([7])) + ] + ) + + #expect(message == nil) + } + + @Test("SharedSolanaLegacyMessage(header:accounts:recentBlockhash:instructions:) rejects an instruction program missing from accounts") + func rejectsInstructionProgramMissingFromAccounts() { + let k1 = Data(repeating: 1, count: 32) + let k2 = Data(repeating: 2, count: 32) + let missingProgram = Data(repeating: 9, count: 32) // not in accounts + + let message = SharedSolanaLegacyMessage( + header: SharedSolanaMessageHeader(requiredSignatures: 1, readOnlySigners: 0, readOnly: 1), + accounts: [.payer(publicKey: k1), .program(publicKey: k2)], + recentBlockhash: Data(repeating: 99, count: 32), + instructions: [ + SharedSolanaInstruction(program: missingProgram, accounts: [.readonly(publicKey: k2)], data: Data([7])) + ] + ) + + #expect(message == nil) + } + + @Test("SharedSolanaLegacyMessage(header:accounts:recentBlockhash:instructions:) constructs and encodes when every reference resolves") + func constructsAndEncodesWhenEveryReferenceResolves() throws { + let k1 = Data(repeating: 1, count: 32) + let k2 = Data(repeating: 2, count: 32) + + let message = try #require(SharedSolanaLegacyMessage( + header: SharedSolanaMessageHeader(requiredSignatures: 1, readOnlySigners: 0, readOnly: 1), + accounts: [.payer(publicKey: k1), .program(publicKey: k2)], + recentBlockhash: Data(repeating: 99, count: 32), + instructions: [ + SharedSolanaInstruction(program: k2, accounts: [.readonly(publicKey: k1)], data: Data([7])) + ] + )) + + // Reachable now that construction succeeded: neither of these traps. + let encoded = message.encode() + #expect(!encoded.isEmpty) + #expect(SharedSolanaMessage.legacy(message).instructions.count == 1) + } } diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt index a4046d2b15..ed02720d88 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt @@ -33,10 +33,14 @@ data class LegacyMessage( val data = mutableListOf() val accounts = accounts.map { it.publicKey } - // `accounts` above is this message's own full account list, built from the same - // `instructions` being compiled below (see `newInstance`), so every program/account an - // instruction references is always present in it — `compile` returning `null` here would - // mean this message's own invariant was violated elsewhere. + // Every caller that can construct a `LegacyMessage` builds this invariant in: within this + // module, only `newInstance` below calls the constructor, and it derives `accounts` from + // these same `instructions`. Across the Kotlin/Native boundary, `LegacyMessage` is not + // itself exported — the exported type is `SharedSolanaLegacyMessage` + // (`kmp/shared-core/spm/.../SolanaMessage.swift`), whose only public initializer validates + // that every instruction's accounts are present in `accounts` and returns `nil` otherwise. + // So a `LegacyMessage` with an instruction referencing an account missing from `accounts` + // cannot exist, and `compile` returning `null` here is unreachable. val instructions = instructions.map { it.compile(accounts) ?: error("instruction references an account missing from this message") } diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Message.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Message.kt index f451365754..8fd4efab3a 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Message.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Message.kt @@ -67,11 +67,12 @@ sealed interface Message { val instructions: List get() = when (this) { - // `accountKeys` above is this message's own full account list (see - // `LegacyMessage.newInstance`), built from the same `message.instructions` being - // compiled here, so every program/account an instruction references is always - // present in it — `compile` returning `null` here would mean this message's own - // invariant was violated elsewhere. + // Same unreachability argument as `LegacyMessage.encode()` (see its comment): + // `accountKeys` is `message`'s own account list, and within this module only + // `LegacyMessage.newInstance` constructs a `LegacyMessage`, deriving `accounts` from + // the same instructions compiled here. Across the Kotlin/Native boundary, the exported + // `SharedSolanaLegacyMessage`'s only public initializer validates this and returns + // `nil` otherwise — so `compile` returning `null` here is unreachable. is Legacy -> message.instructions.map { instruction -> instruction.compile(accountKeys) ?: error("instruction references an account missing from this message") diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt index 3efaece982..01d317cf7e 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt @@ -276,8 +276,14 @@ data class SolanaTransaction(val message: Message, val signatures: List instruction.compile(allAccounts) ?: error("instruction references an account missing from allAccounts") From 249101cd8cad32f5422cd3a58457cc729ecb03e1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 12 Sep 2026 11:51:08 -0400 Subject: [PATCH 23/26] fix(shared-core-kit): make a validated legacy message immutable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SharedSolanaLegacyMessage's public init validates that every instruction's accounts exist in the message's own account list, but the four stored properties were still `var`. A caller could extract the message, mutate `instructions` directly, and reintroduce the dangling reference the initializer rejects — reaching the same Kotlin `error(...)` trap the validation was meant to close. Make the properties `let` so the invariant holds for the value's whole lifetime. `SharedSolanaMessage.recentBlockhash`'s setter no longer mutates a `LegacyMessage` copy in place; it goes through a new `withRecentBlockhash` helper that reconstructs via an unchecked internal initializer, since replacing the blockhash can't affect the accounts/instructions invariant. The `compile(messageAccounts:)!` in `SharedSolanaMessage.instructions` was already resting on construction-time validation alone, which this closes; its comment now cites immutability as the reason `nil` is unreachable there. --- .../Sources/SharedCoreKit/SolanaMessage.swift | 69 +++++++++++++------ 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift index fbf96e4b59..1f329e3129 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift @@ -280,10 +280,10 @@ extension SharedSolanaAddressLookupTable { // MARK: - Legacy message public struct SharedSolanaLegacyMessage: Equatable, Sendable { - public var header: SharedSolanaMessageHeader - public var accounts: [SharedSolanaAccountMeta] - public var recentBlockhash: Data - public var instructions: [SharedSolanaInstruction] + public let header: SharedSolanaMessageHeader + public let accounts: [SharedSolanaAccountMeta] + public let recentBlockhash: Data + public let instructions: [SharedSolanaInstruction] /// Fails if any instruction's `program`, or any of its accounts' `publicKey`, is absent from /// `accounts`. That's the one caller-constructible hole behind the `error(...)` traps in @@ -295,6 +295,12 @@ public struct SharedSolanaLegacyMessage: Equatable, Sendable { /// value this facade can produce, so `encode()` and `SharedSolanaMessage.instructions` below /// can stay total instead of trapping. /// + /// The four stored properties are `let`: validating only at construction is not enough on its + /// own — a `var` property let a caller pull a value back out, mutate `instructions` (or + /// `accounts`) directly, and reintroduce exactly the dangling reference this initializer + /// rejects, without going through it again. Immutability makes the invariant hold for the + /// value's entire lifetime, not just the instant after `init?` returns. + /// /// Decoded messages skip this check via `init(_ message: KotlinLegacyMessage)` below: Kotlin's /// own `LegacyMessage.newInstance` already guarantees the invariant there, so re-validating on /// every decode would be redundant work with no way to ever fail. @@ -321,14 +327,24 @@ public struct SharedSolanaLegacyMessage: Equatable, Sendable { extension SharedSolanaLegacyMessage { // Assigns stored properties directly rather than delegating to the public, validating // `init?(header:accounts:recentBlockhash:instructions:)`: that initializer is failable, and a - // non-failable initializer cannot delegate to one written `init?`. Delegating is unnecessary - // here anyway — Kotlin's own `LegacyMessage.newInstance` already guarantees this invariant for - // any `KotlinLegacyMessage` that exists, so this path has no validation to perform. + // non-failable initializer cannot delegate to one written `init?`. Only for use where the + // invariant can be shown to hold without re-running the check: from a `KotlinLegacyMessage` + // (Kotlin's own `LegacyMessage.newInstance` already guarantees it) or when replacing + // `recentBlockhash` (`withRecentBlockhash` below), which the invariant doesn't depend on. + private init(uncheckedHeader header: SharedSolanaMessageHeader, accounts: [SharedSolanaAccountMeta], recentBlockhash: Data, instructions: [SharedSolanaInstruction]) { + self.header = header + self.accounts = accounts + self.recentBlockhash = recentBlockhash + self.instructions = instructions + } + init(_ message: KotlinLegacyMessage) { - self.header = SharedSolanaMessageHeader(message.header) - self.accounts = message.accounts.map(SharedSolanaAccountMeta.init) - self.recentBlockhash = Data(message.recentBlockhash.byteArray) - self.instructions = message.instructions.map(SharedSolanaInstruction.init) + self.init( + uncheckedHeader: SharedSolanaMessageHeader(message.header), + accounts: message.accounts.map(SharedSolanaAccountMeta.init), + recentBlockhash: Data(message.recentBlockhash.byteArray), + instructions: message.instructions.map(SharedSolanaInstruction.init) + ) } var kotlin: KotlinLegacyMessage { @@ -339,6 +355,16 @@ extension SharedSolanaLegacyMessage { instructions: instructions.map { $0.kotlin } ) } + + /// Returns a copy with `recentBlockhash` replaced, used by `SharedSolanaMessage.recentBlockhash`'s + /// setter below now that `accounts`/`instructions` are `let` and can no longer be mutated in + /// place. Goes through the unchecked initializer above rather than the validating one: that + /// check only relates `accounts` to `instructions`, neither of which changes here, so `self` + /// being valid already (the only way a `SharedSolanaLegacyMessage` can exist) guarantees the + /// copy is too — no `Optional` to force-unwrap. + func withRecentBlockhash(_ recentBlockhash: Data) -> SharedSolanaLegacyMessage { + SharedSolanaLegacyMessage(uncheckedHeader: header, accounts: accounts, recentBlockhash: recentBlockhash, instructions: instructions) + } } // MARK: - Versioned V0 message @@ -439,9 +465,8 @@ public enum SharedSolanaMessage: Equatable, Sendable { } set { switch self { - case .legacy(var message): - message.recentBlockhash = newValue - self = .legacy(message) + case .legacy(let message): + self = .legacy(message.withRecentBlockhash(newValue)) case .versionedV0(var message): message.recentBlockhash = newValue self = .versionedV0(message) @@ -457,12 +482,16 @@ public enum SharedSolanaMessage: Equatable, Sendable { case .legacy(let message): // `accounts` above is this message's own full account list, and `message.instructions` // are this same message's instructions, so every program/account an instruction - // references is always present in `accounts` — `SharedSolanaLegacyMessage`'s only - // public constructor (`init?(header:accounts:recentBlockhash:instructions:)`) rejects - // any value where that would not hold, and the internal `init(_ message: - // KotlinLegacyMessage)` only wraps values Kotlin's own `LegacyMessage.newInstance` - // already built consistently. So `compile` returning `nil` here is unreachable; force- - // unwrap rather than thread an `Optional` through a getter that can never actually fail. + // references is always present in `accounts`. This holds for the lifetime of `message`, + // not just at the moment it was built: `SharedSolanaLegacyMessage`'s four stored + // properties are `let`, so nothing after construction can change `accounts` or + // `instructions` independently of one another and reopen the gap between them. + // Construction itself only ever reaches a consistent pairing — the public + // `init?(header:accounts:recentBlockhash:instructions:)` rejects any value where it + // would not hold, and the internal `init(_ message: KotlinLegacyMessage)` only wraps + // values Kotlin's own `LegacyMessage.newInstance` already built consistently. So + // `compile` returning `nil` here is unreachable; force-unwrap rather than thread an + // `Optional` through a getter that can never actually fail. let accounts = message.accounts.map(\.publicKey) return message.instructions.map { instruction in instruction.compile(messageAccounts: accounts)! From a6c790a91cb2d80bd112acc942218b45ee5fac80 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 12 Sep 2026 11:57:22 -0400 Subject: [PATCH 24/26] fix(solana): bound-check instruction indexes in CompiledInstruction.decompile decompile validated only the count of accountIndexes against accounts.size and then indexed with programIndex/accountIndexes unchecked. Both are u8 on the wire but were read as signed Bytes: a wire byte of 0xFF decodes to -1, and LegacyMessage.newInstance's own guard on programIndex is also a signed comparison that -1 passes, so accounts[-1] throws IndexOutOfBoundsException. A positive index simply past the end of accounts crashes the same way, since the count check never looks at any individual index's value. On Kotlin/Native that exception is an uncaught, fatal trap across the Swift interop boundary rather than a catchable error, and LegacyMessage.newInstance is the only call site, decoding bytes straight off the wire. decompile now reads both indexes unsigned (and 0xFF) and range-checks each against accounts.size, returning null instead of throwing. The redundant signed check in LegacyMessage.newInstance is dropped rather than fixed in place: it only ever rejected values decompile now rejects anyway, and kept around it would contradict the new unsigned check. Adds regression coverage for a negative-when-signed program index (0xFF), a positive out-of-range program index, and an out-of-range account index. --- .../getcode/opencode/solana/Instruction.kt | 18 +++- .../getcode/opencode/solana/LegacyMessage.kt | 10 +- .../opencode/solana/MalformedDecodeTest.kt | 91 +++++++++++++++++++ 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt index 3ae8dfd74d..d47513be28 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/Instruction.kt @@ -109,8 +109,22 @@ data class CompiledInstruction( return null } - val program = accounts[programIndex.toInt()].publicKey - val accountsD = accountIndexes.map { accounts[it.toInt()] } + // Both `programIndex` and each `accountIndexes` entry are `u8` on the wire and must be + // read unsigned (`and 0xFF`) before indexing: `Byte` is signed, so a wire byte of 0xFF + // decodes to -1, and the size check above only bounds the *count* of `accountIndexes`, + // not any individual index's value — a positive index past the end of `accounts` is just + // as unchecked. Range-check every index against `accounts.size` instead of indexing + // unchecked, so malformed input returns `null` here rather than throwing. + val programIdx = programIndex.toInt() and 0xFF + if (programIdx >= accounts.size) return null + + val accountsD = accountIndexes.map { index -> + val accountIdx = index.toInt() and 0xFF + if (accountIdx >= accounts.size) return null + accounts[accountIdx] + } + + val program = accounts[programIdx].publicKey return Instruction( program = program, diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt index ed02720d88..0fceefdfa0 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt @@ -95,10 +95,12 @@ data class LegacyMessage( for (i in 0 until instructionCount) { val instruction = CompiledInstruction.fromList(remainingData) ?: return null - if (instruction.programIndex >= messageAccounts.size) { - return null - } - + // `programIndex` is dropped here rather than range-checked: it's a signed `Byte`, + // so a wire byte of 0xFF (out of range as an unsigned index) reads as -1 and + // would pass a signed `>= messageAccounts.size` comparison anyway. The unsigned, + // full-range check now lives in `CompiledInstruction.decompile` below, which + // rejects both `programIndex` and every `accountIndexes` entry that's out of + // range for `metaAccounts` — a signed check here would only contradict it. remainingData = remainingData.tail(instruction.byteLength) compiledInstructions.add(instruction) } diff --git a/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/MalformedDecodeTest.kt b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/MalformedDecodeTest.kt index bb9057ed83..369828504d 100644 --- a/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/MalformedDecodeTest.kt +++ b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/MalformedDecodeTest.kt @@ -192,4 +192,95 @@ class MalformedDecodeTest { val bytes = listOf(0.toByte(), 1.toByte(), 0.toByte(), 5.toByte(), 0xAA.toByte(), 0xBB.toByte()) assertNull(CompiledInstruction.fromList(bytes)) } + + // --- CompiledInstruction.decompile / LegacyMessage.newInstance: out-of-range indexes --- + // + // `decompile` validated the *count* of `accountIndexes` against `accounts.size` and then + // indexed with `programIndex`/`accountIndexes` unchecked. Both are `u8` on the wire but were + // read as signed `Byte`s: a wire byte of 0xFF decodes to -1, and `LegacyMessage.newInstance`'s + // own guard (`instruction.programIndex >= messageAccounts.size`) is also a signed comparison + // that -1 passes, so `accounts[-1]` throws `IndexOutOfBoundsException` — a fatal, + // uncatchable trap across the Kotlin/Native boundary, not a Swift-catchable error. A + // positive index simply past the end of `accounts` throws the same way, since the count + // check alone says nothing about any individual index's range. + + private fun twoAccountLegacyMessageEncoded(): Pair> { + val accounts = listOf( + AccountMeta.payer(publicKey(1)), + AccountMeta.program(publicKey(2)), + ) + val instruction = Instruction( + program = publicKey(2), + accounts = listOf(AccountMeta.writable(publicKey(1))), + data = listOf(0x01), + ) + val message = LegacyMessage.newInstance( + accounts = accounts, + recentBlockhash = hash(9), + instructions = listOf(instruction), + ) + return message to message.encode().toList() + } + + /** Byte offset of the (single) instruction's `programIndex` within [encoded]. */ + private fun programIndexOffset(message: LegacyMessage, encoded: List): Int { + val accountCount = message.accounts.size + val accountsSectionLen = ShortVec.encodeLen(accountCount).size + accountCount * LENGTH_32 + val accountsEnd = MessageHeader.length + accountsSectionLen + val hashEnd = accountsEnd + LENGTH_32 + val instructionCount = message.instructions.size + return hashEnd + ShortVec.encodeLen(instructionCount).size + } + + @Test + fun legacyMessageNewInstanceNegativeWhenSignedProgramIndexReturnsNull() { + val (message, encoded) = twoAccountLegacyMessageEncoded() + assertNotNull(LegacyMessage.newInstance(encoded)) + + val corrupted = encoded.toMutableList() + // 0xFF read as a signed Byte is -1, which used to pass the `>= messageAccounts.size` + // guard and then crash indexing `accounts[-1]`. + corrupted[programIndexOffset(message, encoded)] = 0xFF.toByte() + + assertNull(LegacyMessage.newInstance(corrupted)) + } + + @Test + fun compiledInstructionDecompilePositiveOutOfRangeProgramIndexReturnsNull() { + // Unit-tests `decompile` directly rather than through `LegacyMessage.newInstance`: a + // small positive out-of-range `programIndex` (2, with only 2 accounts) is still caught by + // `LegacyMessage.newInstance`'s own `programIndex >= messageAccounts.size` guard even + // before the fix, since that comparison is only wrong for values that go negative when + // read as a signed `Byte` (128-255). `decompile`'s own count check + // (`accounts.size < accountIndexes.size + 1`) never looks at `programIndex`'s value at + // all, so calling it directly is what actually exercises the bug for this case. + val accounts = listOf( + AccountMeta.payer(publicKey(1)), + AccountMeta.program(publicKey(2)), + ) + val compiled = CompiledInstruction( + programIndex = accounts.size.toByte(), // 2 is past the end of a 2-account list + accountIndexes = listOf(0), + data = listOf(0x01), + ) + + assertNull(compiled.decompile(accounts)) + } + + @Test + fun legacyMessageNewInstanceOutOfRangeAccountIndexReturnsNull() { + val (message, encoded) = twoAccountLegacyMessageEncoded() + assertNotNull(LegacyMessage.newInstance(encoded)) + + val instruction = message.instructions.single() + val accountIndexCountLen = ShortVec.encodeLen(instruction.accounts.size).size + val accountIndexOffset = programIndexOffset(message, encoded) + 1 + accountIndexCountLen + + val corrupted = encoded.toMutableList() + // 5 is well past the end of the 2-account message; the pre-fix count check + // (`accounts.size < accountIndexes.size + 1`) never looks at the index's actual value. + corrupted[accountIndexOffset] = 5.toByte() + + assertNull(LegacyMessage.newInstance(corrupted)) + } } From 58f60f382ab9d37e5dfae7280ead375181a3392b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 12 Sep 2026 12:41:01 -0400 Subject: [PATCH 25/26] chore(shared-core): name the SPM package directory after its published identity SwiftPM derives a path dependency's identity from the directory basename, so the local-override path pointed at kmp/shared-core/spm while Code.xcodeproj holds an XCRemoteSwiftPackageReference with identity flipcash-shared-core-spm that no environment variable can switch. Two identities, same targets: resolving the Flipcash scheme with FLIPCASH_SHARED_CORE_LOCAL set failed outright with "multiple similar targets 'SharedCore', 'SharedCoreKit' appear in package 'spm' and 'flipcash-shared-core-spm'". Renaming the directory to match the published identity collapses the two into one. --- .github/workflows/publish-shared-core.yml | 2 +- .github/workflows/shared-core-tests.yml | 4 ++-- .../{spm => flipcash-shared-core-spm}/.gitignore | 0 .../{spm => flipcash-shared-core-spm}/Package.swift | 0 .../Sources/SharedCoreKit/Base58.swift | 0 .../Sources/SharedCoreKit/BondingCurve.swift | 0 .../SharedCoreKit/Data+KotlinByteArray.swift | 0 .../Sources/SharedCoreKit/Derivation.swift | 0 .../Sources/SharedCoreKit/Ed25519.swift | 0 .../Sources/SharedCoreKit/Hashes.swift | 0 .../Sources/SharedCoreKit/KikCode+Badge.swift | 0 .../Sources/SharedCoreKit/KikCode+Figure.swift | 0 .../Sources/SharedCoreKit/KikCode.swift | 0 .../SharedCoreKit/KotlinByteList+Bridge.swift | 0 .../SharedCoreKit/KotlinLongArray+Bridge.swift | 0 .../Sources/SharedCoreKit/KotlinTypes.swift | 0 .../Sources/SharedCoreKit/SVGPath.swift | 0 .../Sources/SharedCoreKit/SharedCoreInfo.swift | 0 .../Sources/SharedCoreKit/SolanaEncoding.swift | 0 .../Sources/SharedCoreKit/SolanaMessage.swift | 0 .../Sources/SharedCoreKit/SolanaTransaction.swift | 0 .../Tests/SharedCoreKitTests/Base58Tests.swift | 0 .../Tests/SharedCoreKitTests/DataBridgeTests.swift | 0 .../Tests/SharedCoreKitTests/DerivationTests.swift | 0 .../SharedCoreKitTests/DiscreteCurveTests.swift | 0 .../Tests/SharedCoreKitTests/Ed25519Tests.swift | 0 .../Tests/SharedCoreKitTests/Fixtures.swift | 0 .../Tests/SharedCoreKitTests/Fixtures/base58.json | 0 .../Fixtures/compact_message.json | 0 .../Tests/SharedCoreKitTests/Fixtures/curve.json | 0 .../Fixtures/curve_edge_cases.json | 0 .../Fixtures/curve_fractional.json | 0 .../Fixtures/discrete_cumulative_table.bin | Bin .../Fixtures/discrete_pricing_table.bin | Bin .../Tests/SharedCoreKitTests/Fixtures/ed25519.json | 0 .../Tests/SharedCoreKitTests/Fixtures/slip10.json | 0 .../SharedCoreKitTests/Fixtures/solana_message.json | 0 .../Tests/SharedCoreKitTests/HashesTests.swift | 0 .../SharedCoreKitTests/KikCodeFigureTests.swift | 0 .../SharedCoreKitTests/SharedCoreKitTests.swift | 0 .../SharedCoreKitTests/SolanaEncodingTests.swift | 0 .../SharedCoreKitTests/SolanaMessageTests.swift | 0 .../com/getcode/opencode/solana/LegacyMessage.kt | 2 +- .../getcode/opencode/solana/SolanaTransaction.kt | 2 +- 44 files changed, 5 insertions(+), 5 deletions(-) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/.gitignore (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Package.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/Base58.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/BondingCurve.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/Data+KotlinByteArray.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/Derivation.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/Ed25519.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/Hashes.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/KikCode+Badge.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/KikCode+Figure.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/KikCode.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/KotlinByteList+Bridge.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/KotlinTypes.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/SVGPath.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/SharedCoreInfo.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/SolanaEncoding.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/SolanaMessage.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Sources/SharedCoreKit/SolanaTransaction.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Base58Tests.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/DataBridgeTests.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/DerivationTests.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/DiscreteCurveTests.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Ed25519Tests.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Fixtures.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Fixtures/base58.json (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Fixtures/compact_message.json (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Fixtures/curve.json (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Fixtures/curve_edge_cases.json (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Fixtures/curve_fractional.json (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Fixtures/discrete_cumulative_table.bin (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Fixtures/discrete_pricing_table.bin (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Fixtures/ed25519.json (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Fixtures/slip10.json (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/Fixtures/solana_message.json (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/HashesTests.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/KikCodeFigureTests.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/SharedCoreKitTests.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/SolanaEncodingTests.swift (100%) rename kmp/shared-core/{spm => flipcash-shared-core-spm}/Tests/SharedCoreKitTests/SolanaMessageTests.swift (100%) diff --git a/.github/workflows/publish-shared-core.yml b/.github/workflows/publish-shared-core.yml index b64595dd20..1bbeb6f7a0 100644 --- a/.github/workflows/publish-shared-core.yml +++ b/.github/workflows/publish-shared-core.yml @@ -87,7 +87,7 @@ jobs: run: | set -euo pipefail rm -rf spm-repo/Sources spm-repo/Tests - cp -R kmp/shared-core/spm/Package.swift kmp/shared-core/spm/Sources kmp/shared-core/spm/Tests spm-repo/ + cp -R kmp/shared-core/flipcash-shared-core-spm/Package.swift kmp/shared-core/flipcash-shared-core-spm/Sources kmp/shared-core/flipcash-shared-core-spm/Tests spm-repo/ - name: Build the XCFramework, upload it, and update Package.swift env: diff --git a/.github/workflows/shared-core-tests.yml b/.github/workflows/shared-core-tests.yml index 3ea8ef1e31..b6781d1968 100644 --- a/.github/workflows/shared-core-tests.yml +++ b/.github/workflows/shared-core-tests.yml @@ -1,6 +1,6 @@ name: SharedCore tests -# The Swift facade in `kmp/shared-core/spm` and the Kotlin/Native halves of the modules it +# The Swift facade in `kmp/shared-core/flipcash-shared-core-spm` and the Kotlin/Native halves of the modules it # exports both need Xcode, so neither runs in the Ubuntu `CI` workflow. This is the macOS lane # that covers them. Path-filtered rather than folded into `CI`: a macOS runner is expensive and # nothing outside these directories can change the answer. @@ -94,7 +94,7 @@ jobs: # the same override the local loop uses; see docs/proto-local-development.md's sibling, # shared-core-local-development.md, in the orchestrator repo. - name: Run the SharedCoreKit tests - working-directory: kmp/shared-core/spm + working-directory: kmp/shared-core/flipcash-shared-core-spm env: FLIPCASH_SHARED_CORE_LOCAL: ${{ github.workspace }} run: | diff --git a/kmp/shared-core/spm/.gitignore b/kmp/shared-core/flipcash-shared-core-spm/.gitignore similarity index 100% rename from kmp/shared-core/spm/.gitignore rename to kmp/shared-core/flipcash-shared-core-spm/.gitignore diff --git a/kmp/shared-core/spm/Package.swift b/kmp/shared-core/flipcash-shared-core-spm/Package.swift similarity index 100% rename from kmp/shared-core/spm/Package.swift rename to kmp/shared-core/flipcash-shared-core-spm/Package.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Base58.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Base58.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/Base58.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Base58.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/BondingCurve.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/BondingCurve.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/BondingCurve.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/BondingCurve.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Derivation.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Derivation.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/Derivation.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Derivation.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Ed25519.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Ed25519.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/Ed25519.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Ed25519.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Hashes.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Hashes.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/Hashes.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Hashes.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Badge.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KikCode+Badge.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Badge.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KikCode+Badge.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Figure.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KikCode+Figure.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Figure.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KikCode+Figure.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KikCode.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/KikCode.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KikCode.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinByteList+Bridge.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KotlinByteList+Bridge.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/KotlinByteList+Bridge.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KotlinByteList+Bridge.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinTypes.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KotlinTypes.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/KotlinTypes.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KotlinTypes.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SVGPath.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SVGPath.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/SVGPath.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SVGPath.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SharedCoreInfo.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SharedCoreInfo.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaEncoding.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SolanaEncoding.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/SolanaEncoding.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SolanaEncoding.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SolanaMessage.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SolanaMessage.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift b/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SolanaTransaction.swift similarity index 100% rename from kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift rename to kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SolanaTransaction.swift diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Base58Tests.swift b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Base58Tests.swift similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Base58Tests.swift rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Base58Tests.swift diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/DataBridgeTests.swift b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/DataBridgeTests.swift similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/DataBridgeTests.swift rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/DataBridgeTests.swift diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/DerivationTests.swift b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/DerivationTests.swift similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/DerivationTests.swift rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/DerivationTests.swift diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/DiscreteCurveTests.swift b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/DiscreteCurveTests.swift similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/DiscreteCurveTests.swift rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/DiscreteCurveTests.swift diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Ed25519Tests.swift b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Ed25519Tests.swift similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Ed25519Tests.swift rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Ed25519Tests.swift diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures.swift b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures.swift similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures.swift rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures.swift diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/base58.json b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/base58.json similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/base58.json rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/base58.json diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve.json b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/curve.json similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve.json rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/curve.json diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve_edge_cases.json b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/curve_edge_cases.json similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve_edge_cases.json rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/curve_edge_cases.json diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve_fractional.json b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/curve_fractional.json similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve_fractional.json rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/curve_fractional.json diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/discrete_cumulative_table.bin b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/discrete_cumulative_table.bin similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/discrete_cumulative_table.bin rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/discrete_cumulative_table.bin diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/discrete_pricing_table.bin b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/discrete_pricing_table.bin similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/discrete_pricing_table.bin rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/discrete_pricing_table.bin diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/ed25519.json b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/ed25519.json similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/ed25519.json rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/ed25519.json diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/slip10.json b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/slip10.json similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/slip10.json rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/slip10.json diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/HashesTests.swift b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/HashesTests.swift similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/HashesTests.swift rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/HashesTests.swift diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/KikCodeFigureTests.swift b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/KikCodeFigureTests.swift similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/KikCodeFigureTests.swift rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/KikCodeFigureTests.swift diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/SharedCoreKitTests.swift b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/SharedCoreKitTests.swift similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/SharedCoreKitTests.swift rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/SharedCoreKitTests.swift diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaEncodingTests.swift b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/SolanaEncodingTests.swift similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaEncodingTests.swift rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/SolanaEncodingTests.swift diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift b/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift similarity index 100% rename from kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift rename to kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt index 0fceefdfa0..bd3be5ea88 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt @@ -37,7 +37,7 @@ data class LegacyMessage( // module, only `newInstance` below calls the constructor, and it derives `accounts` from // these same `instructions`. Across the Kotlin/Native boundary, `LegacyMessage` is not // itself exported — the exported type is `SharedSolanaLegacyMessage` - // (`kmp/shared-core/spm/.../SolanaMessage.swift`), whose only public initializer validates + // (`kmp/shared-core/flipcash-shared-core-spm/.../SolanaMessage.swift`), whose only public initializer validates // that every instruction's accounts are present in `accounts` and returns `nil` otherwise. // So a `LegacyMessage` with an instruction referencing an account missing from `accounts` // cannot exist, and `compile` returning `null` here is unreachable. diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt index 01d317cf7e..2feada9993 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt @@ -279,7 +279,7 @@ data class SolanaTransaction(val message: Message, val signatures: List Date: Sat, 12 Sep 2026 17:57:28 -0400 Subject: [PATCH 26/26] Revert "chore(shared-core): name the SPM package directory after its published identity" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 58f60f382. The rename existed to give the local package the same SwiftPM identity as the published one, so the app scheme would stop failing with `multiple similar targets 'SharedCore', 'SharedCoreKit' appear in package 'spm' and 'flipcash-shared-core-spm'`. The second identity came from an `XCRemoteSwiftPackageReference` in `Code.xcodeproj` that the app target held for a single call — `KikCode.svg`, in `TipCardExport.swift`. Removing that reference on the iOS side fixes the collision at its source and needs no change here. With this revert applied, the app scheme resolves `SharedCore` once at `kmp/shared-core/spm` under the override, builds, and still resolves `0.6.0` from the published URL with the override unset. --- .github/workflows/publish-shared-core.yml | 2 +- .github/workflows/shared-core-tests.yml | 4 ++-- .../{flipcash-shared-core-spm => spm}/.gitignore | 0 .../{flipcash-shared-core-spm => spm}/Package.swift | 0 .../Sources/SharedCoreKit/Base58.swift | 0 .../Sources/SharedCoreKit/BondingCurve.swift | 0 .../SharedCoreKit/Data+KotlinByteArray.swift | 0 .../Sources/SharedCoreKit/Derivation.swift | 0 .../Sources/SharedCoreKit/Ed25519.swift | 0 .../Sources/SharedCoreKit/Hashes.swift | 0 .../Sources/SharedCoreKit/KikCode+Badge.swift | 0 .../Sources/SharedCoreKit/KikCode+Figure.swift | 0 .../Sources/SharedCoreKit/KikCode.swift | 0 .../SharedCoreKit/KotlinByteList+Bridge.swift | 0 .../SharedCoreKit/KotlinLongArray+Bridge.swift | 0 .../Sources/SharedCoreKit/KotlinTypes.swift | 0 .../Sources/SharedCoreKit/SVGPath.swift | 0 .../Sources/SharedCoreKit/SharedCoreInfo.swift | 0 .../Sources/SharedCoreKit/SolanaEncoding.swift | 0 .../Sources/SharedCoreKit/SolanaMessage.swift | 0 .../Sources/SharedCoreKit/SolanaTransaction.swift | 0 .../Tests/SharedCoreKitTests/Base58Tests.swift | 0 .../Tests/SharedCoreKitTests/DataBridgeTests.swift | 0 .../Tests/SharedCoreKitTests/DerivationTests.swift | 0 .../SharedCoreKitTests/DiscreteCurveTests.swift | 0 .../Tests/SharedCoreKitTests/Ed25519Tests.swift | 0 .../Tests/SharedCoreKitTests/Fixtures.swift | 0 .../Tests/SharedCoreKitTests/Fixtures/base58.json | 0 .../Fixtures/compact_message.json | 0 .../Tests/SharedCoreKitTests/Fixtures/curve.json | 0 .../Fixtures/curve_edge_cases.json | 0 .../Fixtures/curve_fractional.json | 0 .../Fixtures/discrete_cumulative_table.bin | Bin .../Fixtures/discrete_pricing_table.bin | Bin .../Tests/SharedCoreKitTests/Fixtures/ed25519.json | 0 .../Tests/SharedCoreKitTests/Fixtures/slip10.json | 0 .../SharedCoreKitTests/Fixtures/solana_message.json | 0 .../Tests/SharedCoreKitTests/HashesTests.swift | 0 .../SharedCoreKitTests/KikCodeFigureTests.swift | 0 .../SharedCoreKitTests/SharedCoreKitTests.swift | 0 .../SharedCoreKitTests/SolanaEncodingTests.swift | 0 .../SharedCoreKitTests/SolanaMessageTests.swift | 0 .../com/getcode/opencode/solana/LegacyMessage.kt | 2 +- .../getcode/opencode/solana/SolanaTransaction.kt | 2 +- 44 files changed, 5 insertions(+), 5 deletions(-) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/.gitignore (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Package.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/Base58.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/BondingCurve.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/Data+KotlinByteArray.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/Derivation.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/Ed25519.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/Hashes.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/KikCode+Badge.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/KikCode+Figure.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/KikCode.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/KotlinByteList+Bridge.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/KotlinTypes.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/SVGPath.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/SharedCoreInfo.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/SolanaEncoding.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/SolanaMessage.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Sources/SharedCoreKit/SolanaTransaction.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Base58Tests.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/DataBridgeTests.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/DerivationTests.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/DiscreteCurveTests.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Ed25519Tests.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Fixtures.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Fixtures/base58.json (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Fixtures/compact_message.json (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Fixtures/curve.json (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Fixtures/curve_edge_cases.json (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Fixtures/curve_fractional.json (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Fixtures/discrete_cumulative_table.bin (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Fixtures/discrete_pricing_table.bin (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Fixtures/ed25519.json (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Fixtures/slip10.json (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/Fixtures/solana_message.json (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/HashesTests.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/KikCodeFigureTests.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/SharedCoreKitTests.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/SolanaEncodingTests.swift (100%) rename kmp/shared-core/{flipcash-shared-core-spm => spm}/Tests/SharedCoreKitTests/SolanaMessageTests.swift (100%) diff --git a/.github/workflows/publish-shared-core.yml b/.github/workflows/publish-shared-core.yml index 1bbeb6f7a0..b64595dd20 100644 --- a/.github/workflows/publish-shared-core.yml +++ b/.github/workflows/publish-shared-core.yml @@ -87,7 +87,7 @@ jobs: run: | set -euo pipefail rm -rf spm-repo/Sources spm-repo/Tests - cp -R kmp/shared-core/flipcash-shared-core-spm/Package.swift kmp/shared-core/flipcash-shared-core-spm/Sources kmp/shared-core/flipcash-shared-core-spm/Tests spm-repo/ + cp -R kmp/shared-core/spm/Package.swift kmp/shared-core/spm/Sources kmp/shared-core/spm/Tests spm-repo/ - name: Build the XCFramework, upload it, and update Package.swift env: diff --git a/.github/workflows/shared-core-tests.yml b/.github/workflows/shared-core-tests.yml index b6781d1968..3ea8ef1e31 100644 --- a/.github/workflows/shared-core-tests.yml +++ b/.github/workflows/shared-core-tests.yml @@ -1,6 +1,6 @@ name: SharedCore tests -# The Swift facade in `kmp/shared-core/flipcash-shared-core-spm` and the Kotlin/Native halves of the modules it +# The Swift facade in `kmp/shared-core/spm` and the Kotlin/Native halves of the modules it # exports both need Xcode, so neither runs in the Ubuntu `CI` workflow. This is the macOS lane # that covers them. Path-filtered rather than folded into `CI`: a macOS runner is expensive and # nothing outside these directories can change the answer. @@ -94,7 +94,7 @@ jobs: # the same override the local loop uses; see docs/proto-local-development.md's sibling, # shared-core-local-development.md, in the orchestrator repo. - name: Run the SharedCoreKit tests - working-directory: kmp/shared-core/flipcash-shared-core-spm + working-directory: kmp/shared-core/spm env: FLIPCASH_SHARED_CORE_LOCAL: ${{ github.workspace }} run: | diff --git a/kmp/shared-core/flipcash-shared-core-spm/.gitignore b/kmp/shared-core/spm/.gitignore similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/.gitignore rename to kmp/shared-core/spm/.gitignore diff --git a/kmp/shared-core/flipcash-shared-core-spm/Package.swift b/kmp/shared-core/spm/Package.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Package.swift rename to kmp/shared-core/spm/Package.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Base58.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Base58.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Base58.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/Base58.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/BondingCurve.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/BondingCurve.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/BondingCurve.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/BondingCurve.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Derivation.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Derivation.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Derivation.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/Derivation.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Ed25519.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Ed25519.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Ed25519.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/Ed25519.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Hashes.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Hashes.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/Hashes.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/Hashes.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KikCode+Badge.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Badge.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KikCode+Badge.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Badge.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KikCode+Figure.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Figure.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KikCode+Figure.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Figure.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KikCode.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KikCode.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/KikCode.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KotlinByteList+Bridge.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinByteList+Bridge.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KotlinByteList+Bridge.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/KotlinByteList+Bridge.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KotlinTypes.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinTypes.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/KotlinTypes.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/KotlinTypes.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SVGPath.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SVGPath.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SVGPath.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/SVGPath.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SharedCoreInfo.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SharedCoreInfo.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SolanaEncoding.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaEncoding.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SolanaEncoding.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/SolanaEncoding.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SolanaMessage.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SolanaMessage.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SolanaTransaction.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Sources/SharedCoreKit/SolanaTransaction.swift rename to kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Base58Tests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Base58Tests.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Base58Tests.swift rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Base58Tests.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/DataBridgeTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/DataBridgeTests.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/DataBridgeTests.swift rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/DataBridgeTests.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/DerivationTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/DerivationTests.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/DerivationTests.swift rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/DerivationTests.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/DiscreteCurveTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/DiscreteCurveTests.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/DiscreteCurveTests.swift rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/DiscreteCurveTests.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Ed25519Tests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Ed25519Tests.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Ed25519Tests.swift rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Ed25519Tests.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures.swift rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/base58.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/base58.json similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/base58.json rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/base58.json diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/curve.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve.json similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/curve.json rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve.json diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/curve_edge_cases.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve_edge_cases.json similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/curve_edge_cases.json rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve_edge_cases.json diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/curve_fractional.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve_fractional.json similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/curve_fractional.json rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve_fractional.json diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/discrete_cumulative_table.bin b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/discrete_cumulative_table.bin similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/discrete_cumulative_table.bin rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/discrete_cumulative_table.bin diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/discrete_pricing_table.bin b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/discrete_pricing_table.bin similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/discrete_pricing_table.bin rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/discrete_pricing_table.bin diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/ed25519.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/ed25519.json similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/ed25519.json rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/ed25519.json diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/slip10.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/slip10.json similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/slip10.json rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/slip10.json diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/HashesTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/HashesTests.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/HashesTests.swift rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/HashesTests.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/KikCodeFigureTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/KikCodeFigureTests.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/KikCodeFigureTests.swift rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/KikCodeFigureTests.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/SharedCoreKitTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/SharedCoreKitTests.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/SharedCoreKitTests.swift rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/SharedCoreKitTests.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/SolanaEncodingTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaEncodingTests.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/SolanaEncodingTests.swift rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaEncodingTests.swift diff --git a/kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift similarity index 100% rename from kmp/shared-core/flipcash-shared-core-spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt index bd3be5ea88..0fceefdfa0 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/LegacyMessage.kt @@ -37,7 +37,7 @@ data class LegacyMessage( // module, only `newInstance` below calls the constructor, and it derives `accounts` from // these same `instructions`. Across the Kotlin/Native boundary, `LegacyMessage` is not // itself exported — the exported type is `SharedSolanaLegacyMessage` - // (`kmp/shared-core/flipcash-shared-core-spm/.../SolanaMessage.swift`), whose only public initializer validates + // (`kmp/shared-core/spm/.../SolanaMessage.swift`), whose only public initializer validates // that every instruction's accounts are present in `accounts` and returns `nil` otherwise. // So a `LegacyMessage` with an instruction referencing an account missing from `accounts` // cannot exist, and `compile` returning `null` here is unreachable. diff --git a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt index 2feada9993..01d317cf7e 100644 --- a/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt +++ b/libs/solana/encoding/src/commonMain/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt @@ -279,7 +279,7 @@ data class SolanaTransaction(val message: Message, val signatures: List