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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions .github/workflows/shared-core-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
name: SharedCore tests

# The Swift facade in `kmp/shared-core/spm` and the Kotlin/Native halves of the modules it
# exports both need Xcode, so neither runs in the Ubuntu `CI` workflow. This is the macOS lane
# that covers them. Path-filtered rather than folded into `CI`: a macOS runner is expensive and
# nothing outside these directories can change the answer.
on:
pull_request:
paths:
- 'kmp/shared-core/**'
- 'libs/codes/kikcode/**'
- 'libs/encryption/**'
- 'gradle/libs.versions.toml'
- '.github/workflows/shared-core-tests.yml'

concurrency:
group: shared-core-tests-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

env:
CI: true

jobs:
shared-core-tests:
name: Run SharedCore tests
runs-on: macos-15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1

# Cheap supply-chain guard: fails the run if gradle/wrapper/gradle-wrapper.jar
# is not a byte-for-byte match for a jar published by Gradle.
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@v4

- name: Setup Java env
uses: actions/setup-java@v3
with:
java-version: '21'
distribution: 'corretto'
cache: 'gradle'

# Gradle configures every project in the build, and the Android app applies the secrets
# plugin, which fails configuration outright when `local.properties` is absent. This lane
# only builds the KMP modules — nothing here compiles the app or talks to any of these
# services — so obviously-fake values are enough to get through configuration. Same
# placeholders the publish workflow writes.
- name: Write placeholder local.properties
run: |
set -euo pipefail
{
echo 'BUGSNAG_API_KEY="00000000000000000000000000000000"'
echo 'GOOGLE_CLOUD_PROJECT_NUMBER=000000000000'
echo 'MIXPANEL_API_KEY="00000000000000000000000000000000"'
echo 'COINBASE_ONRAMP_API_KEY=00000000-0000-0000-0000-000000000000'
} > ./local.properties

# commonTest runs on the JVM in the Ubuntu lane, which says nothing about the Kotlin/Native
# halves — ed25519's actual is cinterop over vendored C, and the rest go through a different
# compiler backend. These compile anyway as part of the XCFramework below; running them is
# nearly free from here.
- name: Run the Kotlin tests on the iOS simulator target
run: |
./gradlew \
:libs:codes:kikcode:iosSimulatorArm64Test \
:libs:encryption:base58:iosSimulatorArm64Test \
:libs:encryption:ed25519:iosSimulatorArm64Test \
:libs:encryption:hmac:iosSimulatorArm64Test \
:libs:encryption:sha256:iosSimulatorArm64Test \
:libs:encryption:sha512:iosSimulatorArm64Test

# The facade suite has to run against the Kotlin in this checkout, not against the last
# published release, or a facade written for an unreleased Kotlin change is untested until
# after the release that would have caught it.
- name: Assemble the XCFramework
run: ./gradlew :kmp:shared-core:assembleSharedCoreReleaseXCFramework

# Named simulators come and go with the runner image, so pick whatever iPhone this one has
# rather than pinning a name that silently stops existing.
- name: Pick a simulator
id: sim
run: |
set -euo pipefail
udid=$(xcrun simctl list devices available -j \
| python3 -c "import json,sys; d=json.load(sys.stdin)['devices']; print(next(x['udid'] for r in sorted(d, reverse=True) for x in d[r] if x['name'].startswith('iPhone')))")
echo "udid=$udid" >> "$GITHUB_OUTPUT"
echo "Using simulator $udid"

# The package is iOS-only and the XCFramework has no host slice, so `swift test` cannot
# run this suite — it goes through a simulator destination. FLIPCASH_SHARED_CORE_LOCAL is
# the same override the local loop uses; see docs/proto-local-development.md's sibling,
# shared-core-local-development.md, in the orchestrator repo.
- name: Run the SharedCoreKit tests
working-directory: kmp/shared-core/spm
env:
FLIPCASH_SHARED_CORE_LOCAL: ${{ github.workspace }}
run: |
set -euo pipefail
xcodebuild test \
-scheme SharedCore \
-destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \
-clonedSourcePackagesDirPath "$RUNNER_TEMP/spm" \
-resultBundlePath "$RUNNER_TEMP/SharedCoreKit.xcresult" \
-quiet

# `-quiet` prints nothing on success, so a run that executed no tests at all reads exactly
# like a run that passed all of them — the one failure mode a gate must not have. Print the
# counts and fail on zero.
- name: Report the test counts
if: always()
run: |
set -euo pipefail
bundle="$RUNNER_TEMP/SharedCoreKit.xcresult"
if [ ! -d "$bundle" ]; then
echo "no result bundle at $bundle — the test step never got as far as running"
exit 1
fi
xcrun xcresulttool get test-results summary --path "$bundle" --format json \
> "$RUNNER_TEMP/summary.json"
python3 -c "
import json, sys
summary = json.load(open(sys.argv[1]))
total = summary.get('totalTestCount', 0)
print(total, 'tests:',
summary.get('passedTests', 0), 'passed,',
summary.get('failedTests', 0), 'failed,',
summary.get('skippedTests', 0), 'skipped')
if total == 0:
sys.exit('the suite executed no tests')
" "$RUNNER_TEMP/summary.json"
3 changes: 2 additions & 1 deletion kmp/shared-core/spm/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ let package = Package(
),
.testTarget(
name: "SharedCoreKitTests",
dependencies: ["SharedCoreKit"]
dependencies: ["SharedCoreKit"],
resources: [.copy("Fixtures")]
),
]
)
17 changes: 17 additions & 0 deletions kmp/shared-core/spm/Sources/SharedCoreKit/Base58.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation
import SharedCore

/// Bitcoin/Solana Base58, computed by the shared Kotlin.
public enum Base58 {

public static func encode(_ data: Data) -> String {
SharedCore.Base58.shared.encode(input: data.kotlinByteArray)
}

/// Returns `nil` for input outside the alphabet. Kotlin throws there, but the throw carries
/// nothing a caller can act on beyond "not Base58".
public static func decode(_ string: String) -> Data? {
guard let bytes = try? SharedCore.Base58.shared.decode(input: string) else { return nil }
return Data(bytes)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,16 @@ extension Data {
}
return array
}

/// The reverse copy, for the exported functions that hand bytes back. Kotlin bytes are signed,
/// so anything above 0x7F comes across negative and has to be reinterpreted rather than
/// converted.
init(_ array: KotlinByteArray) {
var bytes = [UInt8]()
bytes.reserveCapacity(Int(array.size))
for index in 0..<array.size {
bytes.append(UInt8(bitPattern: array.get(index: index)))
}
self.init(bytes)
}
}
42 changes: 42 additions & 0 deletions kmp/shared-core/spm/Sources/SharedCoreKit/Ed25519.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation
import SharedCore

/// Ed25519 signing, computed by the shared Kotlin over the vendored orlp/ed25519 C.
public enum SharedEd25519 {

/// Sizes follow the orlp convention the C library uses: the private key is `seed || publicKey`.
public struct KeyPair: Equatable {
public let publicKey: Data
public let privateKey: Data

public init(publicKey: Data, privateKey: Data) {
self.publicKey = publicKey
self.privateKey = privateKey
}
}

public static func keyPair(seed: Data) -> KeyPair {
let pair = Ed25519Kmp.shared.createKeyPair(seed: seed.kotlinByteArray)
return KeyPair(publicKey: Data(pair.publicKey), privateKey: Data(pair.privateKey))
}

public static func sign(message: Data, keyPair: KeyPair) -> Data {
Data(Ed25519Kmp.shared.sign(
message: message.kotlinByteArray,
publicKey: keyPair.publicKey.kotlinByteArray,
privateKey: keyPair.privateKey.kotlinByteArray
))
}

public static func verify(signature: Data, message: Data, publicKey: Data) -> Bool {
Ed25519Kmp.shared.verify(
signature: signature.kotlinByteArray,
message: message.kotlinByteArray,
publicKey: publicKey.kotlinByteArray
)
}

public static func isOnCurve(publicKey: Data) -> Bool {
Ed25519Kmp.shared.onCurve(publicKey: publicKey.kotlinByteArray)
}
}
54 changes: 54 additions & 0 deletions kmp/shared-core/spm/Sources/SharedCoreKit/Hashes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation
import SharedCore

/// The hash and MAC primitives, computed by the shared Kotlin.
///
/// Namespaced rather than free functions so the call site says where the answer came from while
/// callers still hold their own `Sha256`/`Hmac` types during the swap.
public enum SharedHash {

public static func sha256(_ data: Data) -> Data {
Data(Sha256Hash.companion.hash(input: data.kotlinByteArray))
}

/// SHA-256(SHA-256(data)), the double hash Solana addresses and Bitcoin-derived formats use.
public static func sha256Twice(_ data: Data) -> Data {
Data(Sha256Hash.companion.hashTwice(input: data.kotlinByteArray))
}

public static func sha512(_ data: Data) -> Data {
Data(Sha512.shared.hash(input: data.kotlinByteArray))
}

public static func hmacSHA256(key: Data, message: Data) -> Data {
Data(Hmac.shared.hmac(
algorithm: "HmacSHA256",
key: key.kotlinByteArray,
message: message.kotlinByteArray
))
}

public static func hmacSHA512(key: Data, message: Data) -> Data {
Data(Hmac.shared.hmac(
algorithm: "HmacSHA512",
key: key.kotlinByteArray,
message: message.kotlinByteArray
))
}

/// PBKDF2-HMAC-SHA512. Takes strings rather than bytes because that is the shape BIP-39 needs
/// and the shape the Kotlin exports.
public static func pbkdf2SHA512(
password: String,
salt: String,
iterations: Int,
keyLength: Int
) -> Data {
Data(PBKDF2SHA512.shared.derive(
P: password,
S: salt,
c: Int32(iterations),
dkLen: Int32(keyLength)
))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,5 @@ import SharedCore
public enum SharedCoreInfo {

/// The `:kmp:shared-core` version the linked XCFramework was published at.
// Unqualified on purpose: inside this module the name `SharedCore` resolves to the
// Kotlin object, not the framework it lives in.
public static var version: String { SharedCore.shared.version }
public static var version: String { SharedCore.SharedCoreBuild.shared.version }
}
32 changes: 32 additions & 0 deletions kmp/shared-core/spm/Tests/SharedCoreKitTests/Base58Tests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation
import Testing
@testable import SharedCoreKit

@Suite struct Base58Tests {

struct Fixture: Decodable {
struct Vector: Decodable {
let name: String
let bytes: String
let base58: String
}

let vectors: [Vector]
}

@Test func matchesTheCrossPlatformVectors() throws {
let fixture = try Fixtures.load("base58", as: Fixture.self)
#expect(fixture.vectors.count == 7)

for vector in fixture.vectors {
let bytes = try #require(Data(hex: vector.bytes), "\(vector.name): bad fixture hex")
#expect(Base58.encode(bytes) == vector.base58, "\(vector.name): encode")
#expect(Base58.decode(vector.base58) == bytes, "\(vector.name): decode")
}
}

@Test func rejectsCharactersOutsideTheAlphabet() {
// 0, I, O and l are the four excluded from the Bitcoin alphabet.
#expect(Base58.decode("0OIl") == nil)
}
}
24 changes: 24 additions & 0 deletions kmp/shared-core/spm/Tests/SharedCoreKitTests/DataBridgeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation
import Testing
import SharedCore
@testable import SharedCoreKit

/// Every facade in this module moves bytes across the Kotlin boundary in both directions, so the
/// two copies get their own tests rather than being covered incidentally by whichever facade test
/// happens to run first.
@Suite struct DataBridgeTests {

@Test func roundTripsThroughKotlin() {
let original = Data([0x00, 0x01, 0x7F, 0x80, 0xFF])
#expect(Data(original.kotlinByteArray) == original)
}

@Test func roundTripsEmpty() {
#expect(Data(Data().kotlinByteArray) == Data())
}

@Test func roundTripsEveryByteValue() {
let original = Data((0...255).map { UInt8($0) })
#expect(Data(original.kotlinByteArray) == original)
}
}
Loading
Loading