Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/shared-core-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ jobs:
run: |
./gradlew \
:libs:codes:kikcode:iosSimulatorArm64Test \
:libs:currency-math:discrete-curve:iosSimulatorArm64Test \
:libs:encryption:base58:iosSimulatorArm64Test \
:libs:encryption:ed25519:iosSimulatorArm64Test \
:libs:encryption:hmac:iosSimulatorArm64Test \
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ desugaring = "2.1.5"
kotlincrypto-hash = "0.8.0"
kotlincrypto-macs = "0.8.0"
event-bus = "0.1.0"
bignum = "0.3.10"

bugsnag = "6.27.0"
bugsnag-gradle-plugin = "1.2.0"
Expand Down Expand Up @@ -270,6 +271,7 @@ mixpanel = { module = "com.mixpanel.android:mixpanel-android", version.ref = "mi

# Crypto
sodium-bindings = { module = "com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings-android", version.ref = "sodium-bindings" }
ionspin-bignum = { module = "com.ionspin.kotlin:bignum", version.ref = "bignum" }
eddsa = { module = "net.i2p.crypto:eddsa", version = "0.3.0" }
kotlincrypto-hash-sha2 = { module = "org.kotlincrypto.hash:sha2", version.ref = "kotlincrypto-hash" }
kotlincrypto-macs-hmac-sha2 = { module = "org.kotlincrypto.macs:hmac-sha2", version.ref = "kotlincrypto-macs" }
Expand Down
2 changes: 2 additions & 0 deletions kmp/shared-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ kotlin {
export(project(":libs:encryption:hmac"))
export(project(":libs:encryption:ed25519"))
export(project(":libs:encryption:mnemonic"))
export(project(":libs:currency-math:discrete-curve"))
}
}

Expand All @@ -53,6 +54,7 @@ kotlin {
api(project(":libs:encryption:hmac"))
api(project(":libs:encryption:ed25519"))
api(project(":libs:encryption:mnemonic"))
api(project(":libs:currency-math:discrete-curve"))
}
}
}
Expand Down
58 changes: 58 additions & 0 deletions kmp/shared-core/spm/Sources/SharedCoreKit/BondingCurve.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Foundation
import SharedCore

/// Discrete bonding-curve pricing engine, backed by the shared Kotlin implementation in
/// `:libs:currency-math:discrete-curve`. Amounts cross the boundary as decimal strings; callers
/// convert to/from their own `BigDecimal` immediately.
///
/// Kotlin's `SharedBondingCurve` (the facade `object` in
/// `com.flipcash.libs.currency.math.curve`) exports as an ObjC/Swift class of the same name
/// (`SharedCoreSharedBondingCurve`, `swift_name("SharedBondingCurve")`), accessed through its
/// `.shared` singleton -- not as `<FileName>Kt` top-level functions, since it is a Kotlin `object`
/// rather than file-level functions. That collides with this wrapper's own name, so every call
/// below qualifies the generated type as `SharedCore.SharedBondingCurve` to disambiguate.
public enum SharedBondingCurve {

public static func initialize(pricingTableBytes: Data, cumulativeTableBytes: Data) {
SharedCore.SharedBondingCurve.shared.initialize(
pricingTableBytes: pricingTableBytes.kotlinByteArray,
cumulativeTableBytes: cumulativeTableBytes.kotlinByteArray
)
}

public static func spotPriceAtSupply(supply: Int32) -> String? {
SharedCore.SharedBondingCurve.shared.spotPriceAtSupply(supply: supply)
}

public static func tokensToValue(currentSupply: String, tokens: String) -> String? {
SharedCore.SharedBondingCurve.shared.tokensToValue(currentSupply: currentSupply, tokens: tokens)
}

public static func valueToTokens(currentSupply: Int32, value: String) -> String? {
SharedCore.SharedBondingCurve.shared.valueToTokens(currentSupply: currentSupply, value: value)
}

public static func tokensForValueExchange(currentValue: String, value: String) -> (tokens: String, fx: String)? {
guard let result = SharedCore.SharedBondingCurve.shared.tokensForValueExchange(currentValue: currentValue, value: value) else {
return nil
}
return (result.tokens, result.fx)
}

public static func preciseSupplyFromValue(value: String) -> String {
SharedCore.SharedBondingCurve.shared.preciseSupplyFromValue(value: value)
}

/// `tvlQuarks` is USDC-quarks (USDC's own 6-decimal smallest unit); the shared engine divides
/// by that fixed unit internally rather than a caller-supplied token decimal count. Crosses as
/// a plain non-optional `Int64` -- the underlying Kotlin `supplyFromTVL(tvlQuarks: Long): Long`
/// always resolves to a step index (it snaps out-of-range input to the nearest valid step
/// rather than failing), so there is no nullable/boxed case to unwrap here.
public static func supplyFromTVL(tvlQuarks: Int64) -> Int64 {
SharedCore.SharedBondingCurve.shared.supplyFromTVL(tvlQuarks: tvlQuarks)
}

public static func formattedTable() -> String {
SharedCore.SharedBondingCurve.shared.formattedTable()
}
}
102 changes: 102 additions & 0 deletions kmp/shared-core/spm/Tests/SharedCoreKitTests/DiscreteCurveTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import Testing
import Foundation
@testable import SharedCoreKit

@Suite("SharedBondingCurve")
struct DiscreteCurveTests {

struct Vector: Decodable {
let name: String
let currentSupply: Int32
let tokens: Int32
let spotPrice, value: String
}
struct Fixture: Decodable { let vectors: [Vector] }

struct FractionalVector: Decodable {
let name, currentSupply, tokens, value: String
}
struct FractionalFixture: Decodable { let vectors: [FractionalVector] }

struct ValueToTokensVector: Decodable {
let name: String
let currentSupply: Int32
let value: String
let tokens: String?
}
struct TokensForValueExchangeVector: Decodable {
let name, currentValue, value: String
let tokens, fx: String?
}
struct EdgeCaseFixture: Decodable {
let valueToTokens: [ValueToTokensVector]
let tokensForValueExchange: [TokensForValueExchangeVector]
}

static let tablesLoaded: Void = {
let pricing = try! Data(contentsOf: Bundle.module.url(forResource: "discrete_pricing_table", withExtension: "bin", subdirectory: "Fixtures")!)
let cumulative = try! Data(contentsOf: Bundle.module.url(forResource: "discrete_cumulative_table", withExtension: "bin", subdirectory: "Fixtures")!)
SharedBondingCurve.initialize(pricingTableBytes: pricing, cumulativeTableBytes: cumulative)
}()

@Test("curve matches the canonical vectors")
func curveMatchesCanonicalVectors() throws {
_ = Self.tablesLoaded
let fixture = try Fixtures.load("curve", as: Fixture.self)
#expect(!fixture.vectors.isEmpty)

for v in fixture.vectors {
let spot = try #require(SharedBondingCurve.spotPriceAtSupply(supply: v.currentSupply))
#expect(Decimal(string: spot) == Decimal(string: v.spotPrice), "spotPrice mismatch for \(v.name)")

let value = try #require(SharedBondingCurve.tokensToValue(currentSupply: "\(v.currentSupply)", tokens: "\(v.tokens)"))
#expect(Decimal(string: value) == Decimal(string: v.value), "tokensToValue mismatch for \(v.name)")
}
}

@Test("curve matches the fractional vectors")
func curveMatchesFractionalVectors() throws {
_ = Self.tablesLoaded
let fixture = try Fixtures.load("curve_fractional", as: FractionalFixture.self)
#expect(!fixture.vectors.isEmpty)

for v in fixture.vectors {
let value = try #require(SharedBondingCurve.tokensToValue(currentSupply: v.currentSupply, tokens: v.tokens))
#expect(Decimal(string: value) == Decimal(string: v.value), "tokensToValue mismatch for \(v.name)")
}
}

// `Decimal`'s mantissa tops out around 38 significant digits, well short of the 50-sig-fig
// vectors below -- but every expected/actual pair here is either identical well within that
// budget, or (the one known exception, `tfve large tvl`'s `fx`) diverges only at the 50th
// significant digit, past where `Decimal(string:)` even looks. Both sides get truncated to the
// same prefix before comparing, so this stays a meaningful check rather than a silent no-op.
@Test("curve matches the edge-case vectors")
func curveMatchesEdgeCaseVectors() throws {
_ = Self.tablesLoaded
let fixture = try Fixtures.load("curve_edge_cases", as: EdgeCaseFixture.self)
#expect(!fixture.valueToTokens.isEmpty)
#expect(!fixture.tokensForValueExchange.isEmpty)

for v in fixture.valueToTokens {
let actual = SharedBondingCurve.valueToTokens(currentSupply: v.currentSupply, value: v.value)
guard let expected = v.tokens else {
#expect(actual == nil, "expected null for \(v.name), got \(actual ?? "nil")")
continue
}
let actualValue = try #require(actual, "valueToTokens unexpectedly nil for \(v.name)")
#expect(Decimal(string: actualValue) == Decimal(string: expected), "valueToTokens mismatch for \(v.name)")
}

for v in fixture.tokensForValueExchange {
let actual = SharedBondingCurve.tokensForValueExchange(currentValue: v.currentValue, value: v.value)
guard let expectedTokens = v.tokens, let expectedFx = v.fx else {
#expect(actual == nil, "expected null for \(v.name), got \(String(describing: actual))")
continue
}
let result = try #require(actual, "tokensForValueExchange unexpectedly nil for \(v.name)")
#expect(Decimal(string: result.tokens) == Decimal(string: expectedTokens), "tokens mismatch for \(v.name)")
#expect(Decimal(string: result.fx) == Decimal(string: expectedFx), "fx mismatch for \(v.name)")
}
}
}
97 changes: 97 additions & 0 deletions kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/curve.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
{
"algorithm": "discrete-bonding-curve",
"units": "currentSupply & tokens in whole tokens; spotPrice & value in USDC (18-dp fixed point on-chain)",
"note": "tokensToValue is exact integer arithmetic on the shared u128 tables; ground truth = Rust api/curve.rs.",
"vectors": [
{
"name": "supply=0 tokens=50",
"note": "within single step",
"currentSupply": 0,
"tokens": 50,
"spotPrice": "0.01",
"value": "0.5",
"valueScaled": "500000000000000000"
},
{
"name": "supply=0 tokens=100",
"note": "exact step boundary",
"currentSupply": 0,
"tokens": 100,
"spotPrice": "0.01",
"value": "1",
"valueScaled": "1000000000000000000"
},
{
"name": "supply=50 tokens=50",
"note": "start mid-step, end on boundary (zero end-partial)",
"currentSupply": 50,
"tokens": 50,
"spotPrice": "0.01",
"value": "0.5",
"valueScaled": "500000000000000000"
},
{
"name": "supply=50 tokens=150",
"note": "partial start + full step + boundary end",
"currentSupply": 50,
"tokens": 150,
"spotPrice": "0.01",
"value": "1.5000877213746469",
"valueScaled": "1500087721374646900"
},
{
"name": "supply=75 tokens=350",
"note": "multi-step with both partials (Rust test)",
"currentSupply": 75,
"tokens": 350,
"spotPrice": "0.01",
"value": "3.500614091946595975",
"valueScaled": "3500614091946595975"
},
{
"name": "supply=0 tokens=200",
"note": "two full steps (cumulative subtraction)",
"currentSupply": 0,
"tokens": 200,
"spotPrice": "0.01",
"value": "2.0000877213746469",
"valueScaled": "2000087721374646900"
},
{
"name": "supply=99 tokens=1",
"note": "cross a step boundary buying 1",
"currentSupply": 99,
"tokens": 1,
"spotPrice": "0.01",
"value": "0.01",
"valueScaled": "10000000000000000"
},
{
"name": "supply=100 tokens=1",
"note": "exactly at boundary, buy 1 (single step)",
"currentSupply": 100,
"tokens": 1,
"spotPrice": "0.010000877213746469",
"value": "0.010000877213746469",
"valueScaled": "10000877213746469"
},
{
"name": "supply=1000000 tokens=500",
"note": "high supply: cumulative entries exceed u64 (iOS slow path)",
"currentSupply": 1000000,
"tokens": 500,
"spotPrice": "0.024040991835086708",
"value": "12.0226050113995003",
"valueScaled": "12022605011399500300"
},
{
"name": "supply=20999900 tokens=100",
"note": "final step near max supply (21,000,000)",
"currentSupply": 20999900,
"tokens": 100,
"spotPrice": "999912.28630835324063318",
"value": "99991228.630835324063318",
"valueScaled": "99991228630835324063318000"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
{
"algorithm": "discrete-bonding-curve-edge-cases",
"units": "currentSupply in whole tokens; value/currentValue/tokens/fx as decimal strings",
"note": "Reference for valueToTokens and tokensForValueExchange -- the binary-search paths not covered by curve.json/curve_fractional.json. Computed with Python's own decimal.Decimal under a 50-significant-digit, half-even context (matching both engines' curveDecimalMode/MathContext(50, HALF_EVEN) contract), applying the same per-operation rounding sequence as DiscreteCurveEngine, not exact math rounded once at the end. A null `tokens` means the operation is expected to fail (nil/None) on both platforms.",
"valueToTokens": [
{
"name": "v2t supply=0 value=0",
"note": "zero value must return zero tokens, not error",
"currentSupply": 0,
"value": "0",
"tokens": "0"
},
{
"name": "v2t supply=0 value=0.005",
"note": "sub-step-cost value: cheaper than completing step 0",
"currentSupply": 0,
"value": "0.005",
"tokens": "0.5"
},
{
"name": "v2t supply=0 value=1",
"note": "exact step-boundary value (100 tokens at 0.01)",
"currentSupply": 0,
"value": "1",
"tokens": "100"
},
{
"name": "v2t supply=50 value=0.5",
"note": "start mid-step, value completes the step exactly",
"currentSupply": 50,
"value": "0.5",
"tokens": "50"
},
{
"name": "v2t supply=0 value=1000000",
"note": "large value spanning many steps",
"currentSupply": 0,
"value": "1000000",
"tokens": "5113574.1872853167202006876072146859824443611523046"
},
{
"name": "v2t near-max-supply",
"note": "one step short of the table's end",
"currentSupply": 20999900,
"value": "0.01",
"tokens": "0.000000010000877213860133629692894336713126869858172282752"
},
{
"name": "v2t at-max-supply",
"note": "at the last valid step -- expect null (at max supply)",
"currentSupply": 21000000,
"value": "0.01",
"tokens": null
}
],
"tokensForValueExchange": [
{
"name": "tfve basic",
"note": "ordinary partial exchange, well within supply",
"currentValue": "100",
"value": "50",
"tokens": "4967.5445632485631595035720135594574717069407144963",
"fx": "0.010065334968490372964726687332098368029638764564859"
},
{
"name": "tfve full drain",
"note": "value equals currentValue exactly -- new supply must land at zero",
"currentValue": "1",
"value": "1",
"tokens": "100",
"fx": "0.01"
},
{
"name": "tfve small value",
"note": "smallest representable quark-aligned value",
"currentValue": "1000",
"value": "0.0000000001",
"tokens": "0.000000009194005556916857318740538960675280939",
"fx": "0.010876652116526919999999999999999999999648919512133"
},
{
"name": "tfve large tvl",
"note": "high-TVL exchange, crosses many steps",
"currentValue": "1000000",
"value": "500000",
"tokens": "777426.1398510714931516338062633651393487230743959",
"fx": "0.64314791382726474631909885149869827831897738605144"
}
]
}
Loading
Loading