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/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/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/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/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/Sources/SharedCoreKit/SolanaMessage.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift new file mode 100644 index 0000000000..1f329e3129 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaMessage.swift @@ -0,0 +1,532 @@ +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: KotlinMessageHeader) { + self.init( + requiredSignatures: Int(header.requiredSignatures), + readOnlySigners: Int(header.readOnlySigners), + readOnly: Int(header.readOnly) + ) + } + + var kotlin: KotlinMessageHeader { + KotlinMessageHeader( + 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: KotlinAccountMeta) { + self.init( + publicKey: Data(meta.publicKey.byteArray), + isSigner: meta.isSigner, + isWritable: meta.isWritable, + isPayer: meta.isPayer, + isProgram: meta.isProgram + ) + } + + var kotlin: KotlinAccountMeta { + KotlinAccountMeta( + publicKey: KotlinPublicKey(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 + } + + /// 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) } + guard let result = kotlin.compile(messageAccounts: accounts) else { return nil } + return SharedSolanaCompiledInstruction(result) + } +} + +extension SharedSolanaInstruction { + init(_ instruction: KotlinInstruction) { + self.init( + program: Data(instruction.program.byteArray), + accounts: instruction.accounts.map(SharedSolanaAccountMeta.init), + data: Data(kotlinByteList: instruction.data) + ) + } + + var kotlin: KotlinInstruction { + KotlinInstruction( + program: KotlinPublicKey(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 = KotlinCompiledInstruction.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: KotlinCompiledInstruction) { + self.init( + programIndex: UInt8(bitPattern: instruction.programIndex), + accountIndexes: [UInt8](kotlinByteList: instruction.accountIndexes), + data: Data(kotlinByteList: instruction.data) + ) + } + + var kotlin: KotlinCompiledInstruction { + KotlinCompiledInstruction( + 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: KotlinMessageAddressLookupTable) { + self.init( + publicKey: Data(lookup.publicKey.byteArray), + writableIndexes: [UInt8](kotlinByteList: lookup.writableIndexes), + readonlyIndexes: [UInt8](kotlinByteList: lookup.readonlyIndexes) + ) + } + + var kotlin: KotlinMessageAddressLookupTable { + KotlinMessageAddressLookupTable( + publicKey: KotlinPublicKey(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: KotlinAddressLookupTable { + KotlinAddressLookupTable( + publicKey: KotlinPublicKey(bytes: publicKey.kotlinByteList), + addresses: addresses.map { KotlinPublicKey(bytes: $0.kotlinByteList) } + ) + } +} + +// MARK: - Legacy message + +public struct SharedSolanaLegacyMessage: Equatable, Sendable { + 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 + /// 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. + /// + /// 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. + 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 + self.instructions = instructions + } + + public func encode() -> Data { + Data(kotlin.encode()) + } +} + +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?`. 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.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 { + KotlinLegacyMessage( + header: header.kotlin, + accounts: accounts.map { $0.kotlin }, + recentBlockhash: KotlinKey32(bytes: recentBlockhash.kotlinByteList), + 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 + +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: KotlinVersionedMessageV0) { + 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: KotlinVersionedMessageV0 { + KotlinVersionedMessageV0( + header: header.kotlin, + staticAccountKeys: staticAccountKeys.map { KotlinPublicKey(bytes: $0.kotlinByteList) }, + recentBlockhash: KotlinKey32(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(let message): + self = .legacy(message.withRecentBlockhash(newValue)) + 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): + // `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`. 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)! + } + 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 = KotlinMessageCompanion.shared.doNewInstance(data: data.kotlinByteList) else { return nil } + switch result { + case let legacy as KotlinMessageLegacy: + self = .legacy(SharedSolanaLegacyMessage(legacy.message)) + 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 new file mode 100644 index 0000000000..90a708f21c --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SolanaTransaction.swift @@ -0,0 +1,110 @@ +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, 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 let result = KotlinSolanaTransaction.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 = KotlinSolanaTransaction.companion.doNewInstance( + payer: KotlinPublicKey(bytes: payer.kotlinByteList), + recentBlockhash: recentBlockhash.map { KotlinKey32(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 = 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 } + ) + self.init(result) + } +} + +extension SharedSolanaTransaction { + init(_ transaction: KotlinSolanaTransaction) { + let message: SharedSolanaMessage + switch transaction.message { + case let legacy as KotlinMessageLegacy: + message = .legacy(SharedSolanaLegacyMessage(legacy.message)) + case let v0 as KotlinMessageVersionedV0: + message = .versionedV0(SharedSolanaVersionedMessageV0(v0.message)) + default: + preconditionFailure("KotlinMessage has only Legacy and VersionedV0 cases") + } + self.init( + message: message, + signatures: transaction.signatures.map { Data($0.byteArray) } + ) + } + + var kotlin: KotlinSolanaTransaction { + let kotlinMessage: KotlinMessage + switch message { + case .legacy(let m): + kotlinMessage = KotlinMessageLegacy(message: m.kotlin) + case .versionedV0(let m): + kotlinMessage = KotlinMessageVersionedV0(message: m.kotlin) + } + return KotlinSolanaTransaction( + message: kotlinMessage, + signatures: signatures.map { KotlinSignature(bytes: $0.kotlinByteList) } + ) + } +} diff --git a/services/opencode/src/test/resources/compact_message.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json similarity index 100% rename from services/opencode/src/test/resources/compact_message.json rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/compact_message.json diff --git a/services/opencode/src/test/resources/solana_message.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json similarity index 100% rename from services/opencode/src/test/resources/solana_message.json rename to kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/solana_message.json 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) + } +} 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..007641259a --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/SolanaMessageTests.swift @@ -0,0 +1,243 @@ +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) + } + + // 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/encryption/keys/build.gradle.kts b/libs/encryption/keys/build.gradle.kts index 4e4c81f68c..9c8a2e1b1c 100644 --- a/libs/encryption/keys/build.gradle.kts +++ b/libs/encryption/keys/build.gradle.kts @@ -1,21 +1,52 @@ plugins { - alias(libs.plugins.flipcash.android.library) + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.kotlin.parcelize) + 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/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/androidMain/kotlin/com/getcode/solana/keys/ByteStringKeys.kt b/libs/encryption/keys/src/androidMain/kotlin/com/getcode/solana/keys/ByteStringKeys.kt new file mode 100644 index 0000000000..c4ce46d402 --- /dev/null +++ b/libs/encryption/keys/src/androidMain/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/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/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/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 91% 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 864e11a62d..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) { @@ -13,7 +12,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 @@ -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 61% 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 index 5a4ee3532a..e1fbd46a10 100644 --- 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 @@ -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/main/kotlin/com/getcode/solana/keys/PublicKey.kt b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/PublicKey.kt similarity index 52% 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 index 108222754f..e72f35446b 100644 --- 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 @@ -1,19 +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 com.google.protobuf.ByteString 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,18 +19,7 @@ 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 - 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 { @@ -54,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/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 75% 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 index 1fb5238104..4751e08d9b 100644 --- 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 @@ -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/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 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 69% 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 index 761405d5a8..ecf12bb1ec 100644 --- 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 @@ -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)) + } } 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 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/libs/solana/encoding/build.gradle.kts b/libs/solana/encoding/build.gradle.kts new file mode 100644 index 0000000000..d660217b55 --- /dev/null +++ b/libs/solana/encoding/build.gradle.kts @@ -0,0 +1,47 @@ +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 { + 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")) + implementation(libs.kotlinx.serialization.json) + } + } + } +} 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 51% 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 index ae0a7bfb68..65f2776d78 100644 --- 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 @@ -1,43 +1,49 @@ package com.getcode.opencode.internal.solana -import com.getcode.opencode.internal.solana.utils.DataSlice.tail -import java.io.ByteArrayInputStream +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 stream that the length is encoded in - * @return - returns the decoded length of the ShortVec and Offset + * @param input - the input list that the length is encoded in + * @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). */ - 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) + if (offset >= input.size || offset >= MAX_LEN_BYTES) return null + 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) - } + if (value < 0) return null - /** - * 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)) } 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 64% 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 46544031dc..d47513be28 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 @@ -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 @@ -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( @@ -29,7 +42,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 @@ -96,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, @@ -118,15 +145,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/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 66% 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 index bf3dc8b7d7..0fceefdfa0 100644 --- 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 @@ -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 @@ -33,7 +33,17 @@ data class LegacyMessage( val data = mutableListOf() val accounts = accounts.map { it.publicKey } - val instructions = instructions.map { it.compile(accounts) } + // 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") + } data.addAll(header.encode().toList()) data.addAll(ShortVec.encodeList(accounts.map { it.bytes })) @@ -47,13 +57,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,23 +79,28 @@ 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) { 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/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 84% 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 index 032bc1b569..8fd4efab3a 100644 --- 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 @@ -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 @@ -67,8 +67,15 @@ sealed interface Message { val instructions: List get() = when (this) { + // 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") } is VersionedV0 -> message.instructions } 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 90% 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 12913358f9..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 @@ -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( @@ -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/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() + } +} 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 77% 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 299cd2fe8f..01d317cf7e 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 @@ -1,12 +1,9 @@ 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.opencode.internal.solana.utils.printDiff -import com.getcode.opencode.internal.solana.utils.printMatch +import com.getcode.utils.DataSlice.chunk +import com.getcode.utils.DataSlice.tail import com.getcode.opencode.model.transactions.AddressLookupTable import com.getcode.solana.keys.AccountMeta import com.getcode.solana.keys.Hash @@ -15,7 +12,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 @@ -62,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 })) @@ -108,21 +74,15 @@ 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 } @@ -313,9 +273,20 @@ data class SolanaTransaction(val message: Message, val signatures: List instruction.compile(allAccounts) + ?: error("instruction references an account missing from allAccounts") } // Create the V0 message @@ -343,68 +314,3 @@ data class SolanaTransaction(val message: Message, val signatures: List): VersionedMessageV0? { if (data.isEmpty()) { - trace(type = TraceType.Error, message = "data is empty") return null } @@ -69,36 +67,39 @@ 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) + // 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) - trace(type = TraceType.Process, message = "static account count: $accountCount") + 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() 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") + val (instructionCount, instructionsData) = ShortVec.decodeLen(payload) ?: return null var remainingInstructionsData = instructionsData val compiledInstructions = mutableListOf() @@ -106,7 +107,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 } @@ -117,14 +117,13 @@ 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() repeat(altCount) { // public key if (remaining.count() < LENGTH_32) { - trace(type = TraceType.Error, message = "not enough data for lookup public key") return null } @@ -132,19 +131,14 @@ 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 } // writable indexes - val (writableIndexLength, writableRemaining) = ShortVec.decodeLen(remaining) + val (writableIndexLength, writableRemaining) = ShortVec.decodeLen(remaining) ?: return null 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 } @@ -152,14 +146,10 @@ 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) { - trace( - type = TraceType.Error, - message = "not enough data for lookup readonly indexes, need: $readonlyIndexLength, have: ${remaining.count()}" - ) return null } 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 new file mode 100644 index 0000000000..124e230943 --- /dev/null +++ b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt @@ -0,0 +1,196 @@ +package com.getcode.opencode.internal.solana + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ShortVecTest { + + // --- encodeLen / decodeLen roundtrip --- + + @Test + fun encodeLenZero() { + val encoded = ShortVec.encodeLen(0) + assertEquals(listOf(0), encoded) + } + + @Test + fun encodeLenSmallValue() { + val encoded = ShortVec.encodeLen(5) + assertEquals(listOf(5), encoded) + } + + @Test + fun encodeLenMaxSingleByte() { + val encoded = ShortVec.encodeLen(0x7f) + assertEquals(1, encoded.size) + assertEquals(0x7f.toByte(), encoded[0]) + } + + @Test + fun encodeLenTwoBytes() { + val encoded = ShortVec.encodeLen(0x80) + assertEquals(2, encoded.size) + } + + @Test + fun encodeLenLargerValue() { + val encoded = ShortVec.encodeLen(0x3FFF) + assertEquals(2, encoded.size) + } + + @Test + fun encodeLenThreeBytes() { + val encoded = ShortVec.encodeLen(0x4000) + assertEquals(3, encoded.size) + } + + @Test + fun roundtripZero() { + val encoded = ShortVec.encodeLen(0) + val (decoded, _) = ShortVec.decodeLen(encoded)!! + assertEquals(0, decoded) + } + + @Test + fun roundtripSmall() { + val encoded = ShortVec.encodeLen(42) + val (decoded, _) = ShortVec.decodeLen(encoded)!! + assertEquals(42, decoded) + } + + @Test + fun roundtripBoundary127() { + val encoded = ShortVec.encodeLen(127) + val (decoded, _) = ShortVec.decodeLen(encoded)!! + assertEquals(127, decoded) + } + + @Test + fun roundtripBoundary128() { + val encoded = ShortVec.encodeLen(128) + val (decoded, _) = ShortVec.decodeLen(encoded)!! + assertEquals(128, decoded) + } + + @Test + fun roundtripBoundary16383() { + val encoded = ShortVec.encodeLen(16383) + val (decoded, _) = ShortVec.decodeLen(encoded)!! + assertEquals(16383, decoded) + } + + @Test + fun roundtripBoundary16384() { + val encoded = ShortVec.encodeLen(16384) + val (decoded, _) = ShortVec.decodeLen(encoded)!! + assertEquals(16384, decoded) + } + + @Test + fun decodeLenReturnsTailBytes() { + val encoded = ShortVec.encodeLen(5) + val extra = listOf(0xA, 0xB, 0xC) + val input = encoded + extra + 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 + fun encodePrependsLength() { + val data = listOf(1, 2, 3) + val encoded = ShortVec.encode(data) + assertEquals(listOf(3, 1, 2, 3), encoded) + } + + @Test + fun encodeEmptyList() { + val encoded = ShortVec.encode(emptyList()) + assertEquals(listOf(0), encoded) + } + + @Test + fun encodeListMultipleItems() { + val items = listOf( + listOf(1, 2), + listOf(3, 4, 5) + ) + val encoded = ShortVec.encodeList(items) + // 2 items, then [1,2], then [3,4,5] + assertEquals(listOf(2, 1, 2, 3, 4, 5), encoded) + } + + @Test + fun encodeListEmpty() { + val encoded = ShortVec.encodeList(emptyList()) + assertEquals(listOf(0), encoded) + } + + @Test + fun encodeListSingleItem() { + val items = listOf(listOf(0xA, 0xB)) + val encoded = ShortVec.encodeList(items) + assertEquals(listOf(1, 0xA, 0xB), encoded) + } +} 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 82% 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 index 473330c579..1f02ddb6f5 100644 --- 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 @@ -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( 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..369828504d --- /dev/null +++ b/libs/solana/encoding/src/commonTest/kotlin/com/getcode/opencode/solana/MalformedDecodeTest.kt @@ -0,0 +1,286 @@ +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)) + } + + // --- 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)) + } +} 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/libs/solana/encoding/src/commonTest/resources/compact_message.json b/libs/solana/encoding/src/commonTest/resources/compact_message.json new file mode 100644 index 0000000000..251e2eb6d5 --- /dev/null +++ b/libs/solana/encoding/src/commonTest/resources/compact_message.json @@ -0,0 +1,58 @@ +{ + "algorithm": "intent-compact-message", + "note": "transfer/withdraw compact-message layout + SHA256 (+ ed25519 signature over the hash).", + "vectors": [ + { + "name": "transfer basic", + "domain": "transfer", + "sourceSeed": 17, + "destinationSeed": 34, + "amount": "123456789", + "nonceSeed": 51, + "nonceValueSeed": 68, + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "message": "7472616e736665721111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222215cd5b070000000033333333333333333333333333333333333333333333333333333333333333334444444444444444444444444444444444444444444444444444444444444444", + "sha256": "01cf89d76b698052aa17e9df85978f433f40f52a1841232855927a2c603443d0", + "signature": "d91ba74e4706216f404547d92bf306be7c62a05f5b660090e90ad04f533db2b63b151f1ba1729673f9cb6e40b2a83e652551a04001d35344a0b879fdd0c47208" + }, + { + "name": "transfer zero", + "domain": "transfer", + "sourceSeed": 17, + "destinationSeed": 34, + "amount": "0", + "nonceSeed": 51, + "nonceValueSeed": 68, + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "message": "7472616e7366657211111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222000000000000000033333333333333333333333333333333333333333333333333333333333333334444444444444444444444444444444444444444444444444444444444444444", + "sha256": "28fad81ba8ab641b7ab912b98b53b9b8e2e7737099dbfe73433f5800fd790ef3", + "signature": "9d25daa0a2eecfde9c7fb2646f2cd9bd144aa43671a25f630dafee285720cbbde64edd593cf79084ab61a545d48685843d108cac50b52ec216ebab323e3f1b03" + }, + { + "name": "transfer max u64", + "domain": "transfer", + "sourceSeed": 170, + "destinationSeed": 187, + "amount": "18446744073709551615", + "nonceSeed": 204, + "nonceValueSeed": 221, + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "message": "7472616e73666572aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbffffffffffffffffccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "sha256": "f304f0a569a8b1c8b95d77bfa47ae54f2a76a7d95331ca7d112861cfd4772613", + "signature": "cc72c4fc8d63ca6767369f74c6813a9e8030525b1da7883b7a57d204c488339a4d9e0f64db09f279108aef2cbd3e304ae975529d989f9bb69d473fffb2113800" + }, + { + "name": "withdraw", + "domain": "withdraw_and_close", + "sourceSeed": 17, + "destinationSeed": 34, + "amount": null, + "nonceSeed": 51, + "nonceValueSeed": 68, + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "message": "77697468647261775f616e645f636c6f73651111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333334444444444444444444444444444444444444444444444444444444444444444", + "sha256": "5cf4168d85f8fb7ae6c3638db5a84d2c6e8a79e7e5c862b8fe4ce0a3ef9039e5", + "signature": "57ae42fb5fb19b9cc53f76826ce25040e0edcb28577c1a19d0e4016407a01a11e293a30a3811b05e0f4244abfcde1fc2914eacadc198b1419a318afb12adf205" + } + ] +} diff --git a/libs/solana/encoding/src/commonTest/resources/solana_message.json b/libs/solana/encoding/src/commonTest/resources/solana_message.json new file mode 100644 index 0000000000..36f9651f6b --- /dev/null +++ b/libs/solana/encoding/src/commonTest/resources/solana_message.json @@ -0,0 +1,108 @@ +{ + "algorithm": "solana-legacy-message", + "note": "Pubkey(seed) = 32 bytes each == seed. Both apps build the message from these inputs and must reproduce expectedMessage from LegacyMessage.encode().", + "vectors": [ + { + "name": "single instruction, 4 accounts", + "accounts": [ + { + "seed": 1, + "role": "payer" + }, + { + "seed": 2, + "role": "writable" + }, + { + "seed": 3, + "role": "readonly" + }, + { + "seed": 9, + "role": "readonly-program" + } + ], + "blockhashSeed": 0, + "instructions": [ + { + "programSeed": 9, + "accountSeeds": [ + 1, + 2, + 3 + ], + "data": "010203" + } + ], + "expectedHeader": "010002", + "expectedMessage": "010002040101010101010101010101010101010101010101010101010101010101010101020202020202020202020202020202020202020202020202020202020202020203030303030303030303030303030303030303030303030303030303030303030909090909090909090909090909090909090909090909090909090909090909000000000000000000000000000000000000000000000000000000000000000001030300010203010203" + }, + { + "name": "minimal: payer + program, empty data", + "accounts": [ + { + "seed": 1, + "role": "payer" + }, + { + "seed": 9, + "role": "readonly-program" + } + ], + "blockhashSeed": 7, + "instructions": [ + { + "programSeed": 9, + "accountSeeds": [ + 1 + ], + "data": "" + } + ], + "expectedHeader": "010001", + "expectedMessage": "010001020101010101010101010101010101010101010101010101010101010101010101090909090909090909090909090909090909090909090909090909090909090907070707070707070707070707070707070707070707070707070707070707070101010000" + }, + { + "name": "readonly co-signer + two instructions", + "accounts": [ + { + "seed": 1, + "role": "payer" + }, + { + "seed": 4, + "role": "readonly-signer" + }, + { + "seed": 2, + "role": "writable" + }, + { + "seed": 9, + "role": "readonly-program" + } + ], + "blockhashSeed": 0, + "instructions": [ + { + "programSeed": 9, + "accountSeeds": [ + 1, + 2 + ], + "data": "ff" + }, + { + "programSeed": 9, + "accountSeeds": [ + 4, + 1 + ], + "data": "1020" + } + ], + "expectedHeader": "020101", + "expectedMessage": "0201010401010101010101010101010101010101010101010101010101010101010101010404040404040404040404040404040404040404040404040404040404040404020202020202020202020202020202020202020202020202020202020202020209090909090909090909090909090909090909090909090909090909090909090000000000000000000000000000000000000000000000000000000000000000020302000201ff03020100021020" + } + ] +} diff --git a/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")) 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/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 3781ce5302..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,8 +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.diff +import com.getcode.opencode.solana.fromBytes 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/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/internal/solana/utils/SolanaTransactionDiff.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/utils/SolanaTransactionDiff.kt new file mode 100644 index 0000000000..60aecdf2bd --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/utils/SolanaTransactionDiff.kt @@ -0,0 +1,81 @@ +package com.getcode.opencode.internal.solana.utils + +import com.getcode.opencode.solana.SolanaTransaction +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 `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 + * `:libs:solana:encoding` now that `MessageHeader` lives there, and this function is the only + * caller, so inlining avoids widening that visibility for one debug string. + */ +internal fun SolanaTransaction.diff(other: SolanaTransaction) { + val lhs = this + val rhs = other + + if (lhs.identifier == rhs.identifier) { + printMatch("ID") + } else { + printDiff( + title = "ID", + one = lhs.identifier.base58(), + two = rhs.identifier.base58() + ) + } + + if (lhs.signatures == rhs.signatures) { + printMatch("Signatures") + } else { + printDiff( + title = "Signatures", + one = lhs.signatures.map { it.base58() }, + two = rhs.signatures.map { it.base58() } + ) + } + + if (lhs.message.header == rhs.message.header) { + printMatch("Header") + } else { + printDiff( + title = "Header", + one = lhs.message.header.let { "H{${it.requiredSignatures}, ${it.readOnlySigners}, ${it.readOnly}}" }, + two = rhs.message.header.let { "H{${it.requiredSignatures}, ${it.readOnlySigners}, ${it.readOnly}}" }, + ) + } + + if (lhs.message.recentBlockhash == rhs.message.recentBlockhash) { + printMatch("Recent Blockhash") + } else { + printDiff( + title = "Recent Blockhash", + one = lhs.recentBlockhash.base58(), + two = rhs.recentBlockhash.base58(), + ) + } + + if (lhs.message.accountKeys== rhs.message.accountKeys) { + printMatch("Accounts") + } else { + printDiff( + title = "Accounts", + one = lhs.message.accountKeys.map { it.description }, + two = rhs.message.accountKeys.map { it.description }, + ) + } + + if (lhs.message.instructions == rhs.message.instructions) { + printMatch("Instructions") + } else { + printDiff( + title = "Instructions", + one = lhs.message.instructions.map { it.description }, + two = rhs.message.instructions.map { it.description }, + ) + } +} 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/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, 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/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 +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt deleted file mode 100644 index c9e5c652c8..0000000000 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.getcode.opencode.internal.solana - -import kotlin.test.Test -import kotlin.test.assertEquals - -class ShortVecTest { - - // --- encodeLen / decodeLen roundtrip --- - - @Test - fun encodeLenZero() { - val encoded = ShortVec.encodeLen(0) - assertEquals(listOf(0), encoded) - } - - @Test - fun encodeLenSmallValue() { - val encoded = ShortVec.encodeLen(5) - assertEquals(listOf(5), encoded) - } - - @Test - fun encodeLenMaxSingleByte() { - val encoded = ShortVec.encodeLen(0x7f) - assertEquals(1, encoded.size) - assertEquals(0x7f.toByte(), encoded[0]) - } - - @Test - fun encodeLenTwoBytes() { - val encoded = ShortVec.encodeLen(0x80) - assertEquals(2, encoded.size) - } - - @Test - fun encodeLenLargerValue() { - val encoded = ShortVec.encodeLen(0x3FFF) - assertEquals(2, encoded.size) - } - - @Test - fun encodeLenThreeBytes() { - val encoded = ShortVec.encodeLen(0x4000) - assertEquals(3, encoded.size) - } - - @Test - fun roundtripZero() { - val encoded = ShortVec.encodeLen(0) - val (decoded, _) = ShortVec.decodeLen(encoded) - assertEquals(0, decoded) - } - - @Test - fun roundtripSmall() { - val encoded = ShortVec.encodeLen(42) - val (decoded, _) = ShortVec.decodeLen(encoded) - assertEquals(42, decoded) - } - - @Test - fun roundtripBoundary127() { - val encoded = ShortVec.encodeLen(127) - val (decoded, _) = ShortVec.decodeLen(encoded) - assertEquals(127, decoded) - } - - @Test - fun roundtripBoundary128() { - val encoded = ShortVec.encodeLen(128) - val (decoded, _) = ShortVec.decodeLen(encoded) - assertEquals(128, decoded) - } - - @Test - fun roundtripBoundary16383() { - val encoded = ShortVec.encodeLen(16383) - val (decoded, _) = ShortVec.decodeLen(encoded) - assertEquals(16383, decoded) - } - - @Test - fun roundtripBoundary16384() { - val encoded = ShortVec.encodeLen(16384) - val (decoded, _) = ShortVec.decodeLen(encoded) - assertEquals(16384, decoded) - } - - @Test - fun decodeLenReturnsTailBytes() { - val encoded = ShortVec.encodeLen(5) - val extra = listOf(0xA, 0xB, 0xC) - val input = encoded + extra - val (value, remaining) = ShortVec.decodeLen(input) - assertEquals(5, value) - assertEquals(extra, remaining) - } - - // --- encode / encodeList --- - - @Test - fun encodePrependsLength() { - val data = listOf(1, 2, 3) - val encoded = ShortVec.encode(data) - assertEquals(listOf(3, 1, 2, 3), encoded) - } - - @Test - fun encodeEmptyList() { - val encoded = ShortVec.encode(emptyList()) - assertEquals(listOf(0), encoded) - } - - @Test - fun encodeListMultipleItems() { - val items = listOf( - listOf(1, 2), - listOf(3, 4, 5) - ) - val encoded = ShortVec.encodeList(items) - // 2 items, then [1,2], then [3,4,5] - assertEquals(listOf(2, 1, 2, 3, 4, 5), encoded) - } - - @Test - fun encodeListEmpty() { - val encoded = ShortVec.encodeList(emptyList()) - assertEquals(listOf(0), encoded) - } - - @Test - fun encodeListSingleItem() { - val items = listOf(listOf(0xA, 0xB)) - val encoded = ShortVec.encodeList(items) - assertEquals(listOf(1, 0xA, 0xB), encoded) - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1245b96476..231f69fa95 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,8 @@ 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 // host vector tests, which can't load on the Linux CI runner (see kmpUnitTestModules). 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