Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2bd8ab8
refactor(keys): replace javaClass equality check with KClass
bmc08gt Sep 11, 2026
8480314
refactor(solana): move DataSlice into libs:encryption:utils
bmc08gt Sep 11, 2026
4700403
refactor(solana): drop ByteArrayInputStream from ShortVec.decodeLen
bmc08gt Sep 11, 2026
8b0603d
refactor(solana): drop trace() calls from VersionedMessage decode path
bmc08gt Sep 11, 2026
aebc5ae
refactor(solana): push ByteString conversions out of key and transact…
bmc08gt Sep 11, 2026
5f7d43f
refactor(keys): convert :libs:encryption:keys to Kotlin Multiplatform
bmc08gt Sep 11, 2026
60ca712
refactor(keys): replace Mint/PublicKey Parcelable with @TypeParceler
bmc08gt Sep 11, 2026
96e34d6
refactor(keys): move keys tests to commonTest/androidHostTest
bmc08gt Sep 11, 2026
2224488
build(solana): scaffold :libs:solana:encoding KMP module
bmc08gt Sep 11, 2026
f4df60d
refactor(solana): move encoding sources into :libs:solana:encoding
bmc08gt Sep 11, 2026
cca44e3
build(opencode): depend on :libs:solana:encoding
bmc08gt Sep 11, 2026
a93e1bd
fix(solana): repoint diff() import after the SolanaTransactionDiff move
bmc08gt Sep 11, 2026
478af9b
refactor(solana): move transaction signing out of :libs:solana:encoding
bmc08gt Sep 11, 2026
faccb90
test(solana): move the message vector suites to commonTest
bmc08gt Sep 11, 2026
0f889df
build: run :libs:encryption:keys host tests via testAndroidHostTest
bmc08gt Sep 11, 2026
bd650a7
feat(shared-core): export solana encoding through a flat Data-only en…
bmc08gt Sep 12, 2026
533862e
feat(shared-core-kit): add Solana encoding facade and vector tests
bmc08gt Sep 12, 2026
05a6f7f
feat(shared-core-kit): add Solana message/transaction type facade
bmc08gt Sep 12, 2026
fbcc840
fix(solana): bound-check ShortVec length decoding against malformed i…
bmc08gt Sep 12, 2026
5bf0fdf
refactor(shared-core-kit): funnel SharedCore qualification through Ko…
bmc08gt Sep 12, 2026
b09f84d
fix(solana): return null from Instruction.compile when an account is …
bmc08gt Sep 12, 2026
14c7261
fix(shared-core-kit): reject a legacy message whose instructions refe…
bmc08gt Sep 12, 2026
249101c
fix(shared-core-kit): make a validated legacy message immutable
bmc08gt Sep 12, 2026
a6c790a
fix(solana): bound-check instruction indexes in CompiledInstruction.d…
bmc08gt Sep 12, 2026
58f60f3
chore(shared-core): name the SPM package directory after its publishe…
bmc08gt Sep 12, 2026
a79885f
Revert "chore(shared-core): name the SPM package directory after its …
bmc08gt Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -167,6 +169,7 @@ sealed interface AppRoute : NavKey, Parcelable {
@Serializable
data class TokenSelection(val purpose: TokenPurpose) : Sheets
@Serializable
@TypeParceler<Mint, MintParceler>()
data class Give(val mint: Mint? = null, val fromTokenInfo: Boolean = false) : Sheets

@Serializable
Expand Down Expand Up @@ -209,6 +212,7 @@ sealed interface AppRoute : NavKey, Parcelable {
@Parcelize
sealed interface Token : AppRoute {
@Serializable
@TypeParceler<Mint, MintParceler>()
data class Info(
val mint: Mint,
val shortfall: Fiat? = null,
Expand All @@ -220,6 +224,7 @@ sealed interface AppRoute : NavKey, Parcelable {
) : Token

@Serializable
@TypeParceler<Mint, MintParceler>()
data class Transactions(val mint: Mint) : Token
@Serializable
data class Swap(
Expand Down Expand Up @@ -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<Mint, MintParceler>()
data class Withdrawal(
val preselectedMint: Mint? = Mint.usdf,
) : Transfers, FlowRouteWithResult<WithdrawalResult> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -21,5 +23,6 @@ sealed interface DepositStep : FlowStep, Parcelable {
data object SelectToken: DepositStep
@Parcelize
@Serializable
@TypeParceler<Mint, MintParceler>()
data class Destination(val mint: Mint) : DepositStep
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Mint, MintParceler>()
data class TokenInfo(val mint: Mint): DeeplinkType, Navigatable

@Serializable data class TipChat(val identifier: ChatIdentifier): DeeplinkType, Navigatable

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PublicKey, PublicKeyParceler>()
data class ExternalWalletConnection(
@SerialName("public_key")
val publicKey: PublicKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Mint, MintParceler>()
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<Mint, MintParceler>()
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<Mint, MintParceler>()
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -27,18 +29,24 @@ sealed interface SwapPurpose : Parcelable {
val mint: Mint
sealed interface BalanceIncrease
sealed interface BalanceDecrease
@Serializable data class Buy(
@Serializable
@TypeParceler<Mint, MintParceler>()
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<Mint, MintParceler>()
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<Mint, MintParceler>()
data class Convert(
override val mint: Mint,
val destinationMint: Mint,
) : SwapPurpose, BalanceDecrease
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -22,6 +24,7 @@ sealed interface WithdrawalStep : FlowStep, Parcelable {
data object SelectToken: WithdrawalStep
@Parcelize
@Serializable
@TypeParceler<Mint, MintParceler>()
data class Amount(val mint: Mint) : WithdrawalStep

@Parcelize
Expand Down
4 changes: 4 additions & 0 deletions kmp/shared-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}

Expand All @@ -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"))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation
import SharedCore

/// Boxed-`List<Byte>` 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<Byte>`-typed properties only exist as
/// `List<Byte>`, which crosses the bridge as `NSArray<KotlinByte *>` — 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<Byte>` 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<Byte>` 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) }
}
}
28 changes: 28 additions & 0 deletions kmp/shared-core/spm/Sources/SharedCoreKit/KotlinTypes.swift
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions kmp/shared-core/spm/Sources/SharedCoreKit/SolanaEncoding.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading