From 068002cf17d449c9891c28d632f090c6b630576f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 6 Sep 2026 08:52:08 -0400 Subject: [PATCH 1/3] refactor(discrete-curve): delegate DiscreteBondingCurve to SharedCoreKit --- .../Models/DiscreteBondingCurve.swift | 564 +++--------------- .../DiscreteBondingCurveTests.swift | 250 -------- 2 files changed, 82 insertions(+), 732 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/DiscreteBondingCurve.swift b/FlipcashCore/Sources/FlipcashCore/Models/DiscreteBondingCurve.swift index 9cf9cfaeb..494009de9 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/DiscreteBondingCurve.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/DiscreteBondingCurve.swift @@ -8,6 +8,7 @@ import Foundation // @preconcurrency: BigDecimal.Rounding not Sendable upstream. @preconcurrency import BigDecimal +import SharedCoreKit /// A discrete step-based bonding curve implementation that uses pre-computed /// lookup tables for deterministic pricing across all clients. @@ -15,6 +16,11 @@ import Foundation /// The curve divides the token supply into steps of 100 tokens each. /// Within each step, the price is constant (taken from the pricing table). /// This ensures exact consistency with the Solana program implementation. +/// +/// The table-driven pricing math is computed by the shared Kotlin engine +/// (`:libs:currency-math:discrete-curve`, exported through `SharedCoreKit`'s +/// `SharedDiscreteCurve`) so both platforms agree exactly -- this type only +/// loads the resource tables and converts to/from `BigDecimal` at the boundary. public struct DiscreteBondingCurve: Sendable { // MARK: - Constants @@ -44,6 +50,23 @@ public struct DiscreteBondingCurve: Sendable { public init() {} + // MARK: - Table Loading + + /// Loads the pricing/cumulative tables into the shared Kotlin engine, once per process. + /// `SharedDiscreteCurve.initialize` itself no-ops on a second call, so re-triggering this + /// from another `DiscreteBondingCurve` instance is harmless. + private static let tablesLoaded: Void = { + guard + let pricingURL = Bundle.module.url(forResource: "discrete_pricing_table", withExtension: "bin"), + let cumulativeURL = Bundle.module.url(forResource: "discrete_cumulative_table", withExtension: "bin"), + let pricingData = try? Data(contentsOf: pricingURL), + let cumulativeData = try? Data(contentsOf: cumulativeURL) + else { + fatalError("Missing discrete curve table resources") + } + SharedDiscreteCurve.initialize(pricingTableBytes: pricingData, cumulativeTableBytes: cumulativeData) + }() + // MARK: - Core Methods /// Returns the spot price at a given supply level. @@ -54,14 +77,11 @@ public struct DiscreteBondingCurve: Sendable { /// - Parameter supply: Current token supply (in whole tokens, not quarks) /// - Returns: Price per token in USDC, or nil if supply exceeds max public func spotPrice(at supply: Int) -> BigDecimal? { - guard supply >= 0, supply <= Self.maxSupply else { return nil } - - let stepIndex = supply / Self.stepSize - guard stepIndex < DiscreteCurveTables.pricingTable.count else { + _ = Self.tablesLoaded + guard let result = SharedDiscreteCurve.spotPriceAtSupply(supply: Int32(supply)) else { return nil } - - return Self.fromScaledU128(DiscreteCurveTables.pricingTable[stepIndex]) + return BigDecimal(result) } /// Calculates the total cost to buy a number of tokens starting at a given supply. @@ -73,8 +93,7 @@ public struct DiscreteBondingCurve: Sendable { /// - tokens: Number of tokens to buy (in whole tokens) /// - Returns: Total cost in USDC, or nil if purchase would exceed max supply public func tokensToValue(currentSupply: Int, tokens: Int) -> BigDecimal? { - guard tokens >= 0, currentSupply >= 0 else { return nil } - return tokensToValue(currentSupply: BigDecimal(currentSupply), tokens: BigDecimal(tokens)) + tokensToValue(currentSupply: BigDecimal(currentSupply), tokens: BigDecimal(tokens)) } /// Calculates the total cost to buy a number of tokens starting at a given supply. @@ -87,52 +106,14 @@ public struct DiscreteBondingCurve: Sendable { /// - tokens: Number of tokens to buy as BigDecimal (can have fractional tokens) /// - Returns: Total cost in USDC, or nil if purchase would exceed max supply public func tokensToValue(currentSupply: BigDecimal, tokens: BigDecimal) -> BigDecimal? { - guard tokens.signum >= 0 else { return nil } - guard tokens.isPositive else { return .zero } - - let stepSizeBD = BigDecimal(Self.stepSize) - let endSupply = currentSupply.add(tokens, Self.rounding) - let startStep = currentSupply.divide(stepSizeBD, Self.rounding).truncatedInt() - let endStep = endSupply.divide(stepSizeBD, Self.rounding).truncatedInt() - - guard endStep < DiscreteCurveTables.pricingTable.count else { + _ = Self.tablesLoaded + guard let result = SharedDiscreteCurve.tokensToValue( + currentSupply: currentSupply.asString(.plain), + tokens: tokens.asString(.plain) + ) else { return nil } - - // Calculate partial tokens in start step (from currentSupply to next step boundary) - let startStepBoundary = BigDecimal(startStep + 1).multiply(stepSizeBD, Self.rounding) - let tokensInStartStep: BigDecimal - if startStepBoundary > endSupply { - // All tokens are within the same step - tokensInStartStep = tokens - } else { - tokensInStartStep = startStepBoundary.subtract(currentSupply, Self.rounding) - } - - // Cost for partial start step - let startPrice = Self.fromScaledU128(DiscreteCurveTables.pricingTable[startStep]) - let startCost = tokensInStartStep.multiply(startPrice, Self.rounding) - - // If start and end are in the same step, we're done - if startStep == endStep { - return startCost - } - - // Cost for complete steps between start_step+1 and end_step-1 (inclusive) - // Use cumulative table: cumulative[end_step] - cumulative[start_step + 1] - let cumulativeStart = Self.fromScaledU128(DiscreteCurveTables.cumulativeTable[startStep + 1]) - let cumulativeEnd = Self.fromScaledU128(DiscreteCurveTables.cumulativeTable[endStep]) - let middleCost = cumulativeEnd.subtract(cumulativeStart, Self.rounding) - - // Calculate partial tokens in end step (from end step boundary to end_supply) - let endStepBoundary = BigDecimal(endStep).multiply(stepSizeBD, Self.rounding) - let tokensInEndStep = endSupply.subtract(endStepBoundary, Self.rounding) - - // Cost for partial end step - let endPrice = Self.fromScaledU128(DiscreteCurveTables.pricingTable[endStep]) - let endCost = tokensInEndStep.multiply(endPrice, Self.rounding) - - return startCost.add(middleCost, Self.rounding).add(endCost, Self.rounding) + return BigDecimal(result) } /// Calculates the number of tokens that can be purchased for a given value. @@ -145,288 +126,62 @@ public struct DiscreteBondingCurve: Sendable { /// - value: Amount of USDC to spend /// - Returns: Number of tokens that can be purchased, or nil if at max supply public func valueToTokens(currentSupply: Int, value: BigDecimal) -> BigDecimal? { - guard value.signum >= 0, currentSupply >= 0 else { return nil } - guard value.isPositive else { return .zero } - - let startStep = currentSupply / Self.stepSize - guard startStep < DiscreteCurveTables.pricingTable.count - 1 else { - return nil - } - - // Calculate cost to complete the current partial step - let startStepBoundary = (startStep + 1) * Self.stepSize - let tokensToCompleteStartStep = startStepBoundary - currentSupply - let startPrice = Self.fromScaledU128(DiscreteCurveTables.pricingTable[startStep]) - let costToCompleteStartStep = BigDecimal(tokensToCompleteStartStep).multiply(startPrice, Self.rounding) - - // If we can't even complete the start step, just divide by price - if value < costToCompleteStartStep { - return value.divide(startPrice, Self.rounding) - } - - // We can at least complete the start step - let remainingAfterStart = value.subtract(costToCompleteStartStep, Self.rounding) - - // Calculate the cumulative value at start_step + 1 (where we'll be after completing start step) - let baseCumulative = Self.fromScaledU128(DiscreteCurveTables.cumulativeTable[startStep + 1]) - - // Target cumulative = base_cumulative + remaining_value - let targetCumulative = baseCumulative.add(remainingAfterStart, Self.rounding) - let targetCumulativeScaled = Self.toScaledU128(targetCumulative) - - // Binary search for the step where cumulative value exceeds or equals target - var low = startStep + 1 - var high = DiscreteCurveTables.cumulativeTable.count - 1 - - while low < high { - let mid = (low + high + 1) / 2 - let midCumulative = DiscreteCurveTables.cumulativeTable[mid] - - if midCumulative <= targetCumulativeScaled { - low = mid - } else { - high = mid - 1 - } - } - - // low is now the last step where cumulative <= target - let endStep = low - - guard endStep < DiscreteCurveTables.pricingTable.count else { + _ = Self.tablesLoaded + guard let result = SharedDiscreteCurve.valueToTokens( + currentSupply: Int32(currentSupply), + value: value.asString(.plain) + ) else { return nil } - - // Calculate tokens from complete steps - let endStepSupply = endStep * Self.stepSize - let tokensFromCompleteSteps = endStepSupply - startStepBoundary - - // Calculate remaining value after complete steps - let cumulativeAtEndStep = Self.fromScaledU128(DiscreteCurveTables.cumulativeTable[endStep]) - let valueUsedForCompleteSteps = cumulativeAtEndStep.subtract(baseCumulative, Self.rounding) - let remainingValue = remainingAfterStart.subtract(valueUsedForCompleteSteps, Self.rounding) - - // Buy partial tokens in end step with remaining value - let endPrice = Self.fromScaledU128(DiscreteCurveTables.pricingTable[endStep]) - let tokensInEndStep = remainingValue.divide(endPrice, Self.rounding) - - // Total tokens - let total = BigDecimal(tokensToCompleteStartStep) - .add(BigDecimal(tokensFromCompleteSteps), Self.rounding) - .add(tokensInEndStep, Self.rounding) - - return total - } - - // MARK: - Utility Methods - - /// Converts a scaled u128 value (18 decimals) to a BigDecimal - private static func fromScaledU128(_ value: UInt128) -> BigDecimal { - // Scale factor: 10^18 - let scaleFactor = BigDecimal("1000000000000000000") - - if value.high == 0 { - // Simple case: fits in UInt64 - use String to avoid DPD encoding issue - return BigDecimal(String(value.low)).divide(scaleFactor, rounding) - } - - // value = high * 2^64 + low - // Use string-based calculation for exactness - // Note: BigDecimal(UInt64) interprets as DPD encoding, so use String instead - let twoToThe64 = BigDecimal("18446744073709551616") // 2^64 - let highPart = BigDecimal(String(value.high)).multiply(twoToThe64, rounding) - let combined = highPart.add(BigDecimal(String(value.low)), rounding) - return combined.divide(scaleFactor, rounding) - } - - /// Converts a BigDecimal to a scaled u128 value (18 decimals) - private static func toScaledU128(_ value: BigDecimal) -> UInt128 { - // Handle negative or zero values - guard value.isPositive else { - return UInt128(0) - } - - let scaleFactor = BigDecimal.ten.pow(tablePrecision, rounding) - let scaled = value.multiply(scaleFactor, rounding) - - // Get the integer part using string manipulation - // Note: Rounding(.towardZero, 0) truncates significant digits incorrectly - // because precision 0 means "0 significant digits", not "0 decimal places" - var str = scaled.asString(.plain) - - // Remove any decimal part - if let dotIndex = str.firstIndex(of: ".") { - str = String(str[.. 1 { - str.removeFirst() - } - - // If it fits in UInt64, use that directly - if let u64 = UInt64(str) { - self.high = 0 - self.low = u64 - return - } - // For larger numbers, we need to divide by 2^64 using digit array arithmetic - // Parse as array of digits and use long division by 2^64 - let digits = str.compactMap { $0.wholeNumberValue } - guard digits.count == str.count else { return nil } - - // Divide the digit array by 2^64 - let (highDigits, lowValue) = Self.divideByPow264(digits: digits) - - // Convert high digits back to UInt64 - var highValue: UInt64 = 0 - for digit in highDigits { - highValue = highValue * 10 + UInt64(digit) - } - - self.high = highValue - self.low = lowValue - } - - /// Divides a decimal number (represented as digits) by 2^64 - /// Returns (quotient digits, remainder as UInt64) - private static func divideByPow264(digits: [Int]) -> ([Int], UInt64) { - // 2^64 = 18446744073709551616 - let divisorDigits: [Int] = [1, 8, 4, 4, 6, 7, 4, 4, 0, 7, 3, 7, 0, 9, 5, 5, 1, 6, 1, 6] - - // If digits represent a number smaller than divisor, quotient is 0 - if digits.count < divisorDigits.count || - (digits.count == divisorDigits.count && compareDigits(digits, divisorDigits) < 0) { - // Convert digits to UInt64 - var value: UInt64 = 0 - for d in digits { - value = value * 10 + UInt64(d) - } - return ([], value) - } - - // Perform long division - var quotientDigits: [Int] = [] - var current: [Int] = [] - - for digit in digits { - current.append(digit) - - // Remove leading zeros from current - while current.count > 1 && current.first == 0 { - current.removeFirst() - } - - // How many times does divisor fit in current? - var count = 0 - while compareDigits(current, divisorDigits) >= 0 { - current = subtractDigits(current, divisorDigits) - count += 1 - } - - quotientDigits.append(count) - } - - // Remove leading zeros from quotient - while quotientDigits.count > 1 && quotientDigits.first == 0 { - quotientDigits.removeFirst() - } - - // Convert remainder (current) to UInt64 - var remainder: UInt64 = 0 - for d in current { - remainder = remainder * 10 + UInt64(d) - } - - return (quotientDigits, remainder) - } - - /// Compare two digit arrays (returns -1, 0, or 1) - private static func compareDigits(_ a: [Int], _ b: [Int]) -> Int { - if a.count != b.count { - return a.count < b.count ? -1 : 1 - } - for (da, db) in zip(a, b) { - if da != db { - return da < db ? -1 : 1 - } - } - return 0 + /// Calculate precise supply from a given value with interpolation within steps. + /// + /// Unlike `supplyFromTVL` which returns step boundaries, this method + /// interpolates within the step to give a more accurate supply value. + /// + /// - Parameter value: Total value in USDC (not quarks) + /// - Returns: Supply as BigDecimal with fractional tokens + private func preciseSupplyFromValue(_ value: BigDecimal) -> BigDecimal { + _ = Self.tablesLoaded + return BigDecimal(SharedDiscreteCurve.preciseSupplyFromValue(value: value.asString(.plain))) } - /// Subtract b from a (assumes a >= b) - private static func subtractDigits(_ a: [Int], _ b: [Int]) -> [Int] { - var result = a - var borrow = 0 - - // Pad b to match length of a - let paddedB = Array(repeating: 0, count: a.count - b.count) + b - - for i in (0.. 1 && result.first == 0 { - result.removeFirst() - } - - return result + /// Calculate supply from TVL using the cumulative table. + /// + /// Uses binary search on the cumulative table to find which step contains + /// the given TVL. Returns the supply at the **start** of that step (the step + /// boundary), not an interpolated value within the step. + /// + /// For example, if TVL corresponds to somewhere between step 5 and step 6, + /// this returns `500` (step 5 boundary), not an interpolated value like `550`. + /// This matches the Rust implementation's step-based lookup behavior. + /// + /// - Parameter tvlQuarks: Total value locked in USDC quarks (6 decimals) + /// - Returns: Current supply in whole tokens at the step boundary + public func supplyFromTVL(_ tvlQuarks: Int) -> Int? { + _ = Self.tablesLoaded + return Int(SharedDiscreteCurve.supplyFromTVL(tvlQuarks: Int64(tvlQuarks))) } - public static func < (lhs: UInt128, rhs: UInt128) -> Bool { - if lhs.high != rhs.high { - return lhs.high < rhs.high + /// Low-level primitive backing the high-level `tokensForValueExchange(fiat:fiatRate:supplyQuarks:)` + /// below -- mirrors Android's `BondingCurve.tokensForValueExchange(currentValue, value)`. + /// + /// - Parameters: + /// - currentValue: Current TVL in USDC + /// - value: USDC value to exchange out of the current TVL + /// - Returns: The tokens removed and the effective fx rate, or nil if the exchange is invalid + /// (value <= 0, value > currentValue, or the resulting tokens are <= 0) + func tokensForValueExchange(currentValue: BigDecimal, value: BigDecimal) -> (tokens: BigDecimal, fx: BigDecimal)? { + _ = Self.tablesLoaded + guard let result = SharedDiscreteCurve.tokensForValueExchange( + currentValue: currentValue.asString(.plain), + value: value.asString(.plain) + ) else { + return nil } - return lhs.low < rhs.low - } - - public static func <= (lhs: UInt128, rhs: UInt128) -> Bool { - lhs < rhs || lhs == rhs + return (BigDecimal(result.tokens), BigDecimal(result.fx)) } } @@ -565,19 +320,8 @@ extension DiscreteBondingCurve { return nil } - // New TVL after subtracting the exchange value - let newTVL = currentTVL.subtract(usdcValue, Self.rounding) - - // Get precise supply at current TVL - let currentSupplyPrecise = preciseSupplyFromValue(currentTVL) - - // Get precise supply at new (lower) TVL - let newSupplyPrecise = preciseSupplyFromValue(newTVL) - - // Tokens = difference in supply - let tokens = currentSupplyPrecise.subtract(newSupplyPrecise, Self.rounding) - - guard tokens.isPositive else { + // Tokens = difference in supply between currentTVL and currentTVL - usdcValue + guard let (tokens, _) = tokensForValueExchange(currentValue: currentTVL, value: usdcValue) else { return nil } @@ -586,98 +330,6 @@ extension DiscreteBondingCurve { return Valuation(tokens: tokens, fx: fx) } - - /// Calculate precise supply from a given value with interpolation within steps. - /// - /// Unlike `supplyFromTVL` which returns step boundaries, this method - /// interpolates within the step to give a more accurate supply value. - /// - /// - Parameter value: Total value in USDC (not quarks) - /// - Returns: Supply as BigDecimal with fractional tokens - private func preciseSupplyFromValue(_ value: BigDecimal) -> BigDecimal { - guard value.isPositive else { - return .zero - } - - // Scale value to table precision (18 decimals) - let valueScaled = Self.toScaledU128(value) - - // Binary search in cumulative table to find the step - var low = 0 - var high = DiscreteCurveTables.cumulativeTable.count - 1 - - while low < high { - let mid = (low + high + 1) / 2 - if DiscreteCurveTables.cumulativeTable[mid] <= valueScaled { - low = mid - } else { - high = mid - 1 - } - } - - let stepIndex = low - let stepSupply = stepIndex * Self.stepSize - - // Get cumulative value at this step boundary - let cumulativeAtStep = Self.fromScaledU128(DiscreteCurveTables.cumulativeTable[stepIndex]) - - // Calculate remaining value within this step - let remainingValue = value.subtract(cumulativeAtStep, Self.rounding) - - guard remainingValue.isPositive else { - return BigDecimal(stepSupply) - } - - // Get price at this step to interpolate - let priceAtStep = Self.fromScaledU128(DiscreteCurveTables.pricingTable[stepIndex]) - - guard priceAtStep.isPositive else { - return BigDecimal(stepSupply) - } - - // Calculate fractional tokens within the step - let fractionalTokens = remainingValue.divide(priceAtStep, Self.rounding) - - // Cap at step size (100) - let stepSizeDecimal = BigDecimal(Self.stepSize) - let cappedFractional = fractionalTokens < stepSizeDecimal - ? fractionalTokens - : stepSizeDecimal - - return BigDecimal(stepSupply).add(cappedFractional, Self.rounding) - } - - /// Calculate supply from TVL using the cumulative table. - /// - /// Uses binary search on the cumulative table to find which step contains - /// the given TVL. Returns the supply at the **start** of that step (the step - /// boundary), not an interpolated value within the step. - /// - /// For example, if TVL corresponds to somewhere between step 5 and step 6, - /// this returns `500` (step 5 boundary), not an interpolated value like `550`. - /// This matches the Rust implementation's step-based lookup behavior. - /// - /// - Parameter tvlQuarks: Total value locked in USDC quarks (6 decimals) - /// - Returns: Current supply in whole tokens at the step boundary, or nil if invalid - public func supplyFromTVL(_ tvlQuarks: Int) -> Int? { - let tvl = BigDecimal(tvlQuarks).divide(BigDecimal(1_000_000), Self.rounding) - let tvlScaled = Self.toScaledU128(tvl) - - // Binary search in cumulative table - var low = 0 - var high = DiscreteCurveTables.cumulativeTable.count - 1 - - while low < high { - let mid = (low + high + 1) / 2 - if DiscreteCurveTables.cumulativeTable[mid] <= tvlScaled { - low = mid - } else { - high = mid - 1 - } - } - - return low * Self.stepSize - } } // MARK: - BigDecimal Extensions @@ -692,56 +344,4 @@ private extension BigDecimal { } return self } - - /// Truncates to integer value (toward zero), returning 0 if conversion fails - func truncatedInt() -> Int { - Int(asString(.plain).split(separator: ".").first ?? "") ?? 0 - } -} - -// MARK: - Lookup Tables - -/// Pre-computed lookup tables for the discrete bonding curve. -/// -/// Tables are loaded from binary resource files at runtime to avoid -/// Swift compiler memory issues with large array literals. -public enum DiscreteCurveTables { - - /// Spot price at each 100-token step (210,001 entries) - /// Values are scaled by 10^18 - public static let pricingTable: [UInt128] = loadTable(named: "discrete_pricing_table") - - /// Cumulative cost from supply 0 to each step (210,001 entries) - /// Values are scaled by 10^18 - public static let cumulativeTable: [UInt128] = loadTable(named: "discrete_cumulative_table") - - /// Load a lookup table from a binary resource file - private static func loadTable(named name: String) -> [UInt128] { - guard let url = Bundle.module.url(forResource: name, withExtension: "bin") else { - fatalError("Missing resource: \(name).bin") - } - - guard let data = try? Data(contentsOf: url) else { - fatalError("Failed to load resource: \(name).bin") - } - - // Each entry is 16 bytes: low UInt64 (8 bytes) + high UInt64 (8 bytes) - // Little-endian format - let entrySize = 16 - let count = data.count / entrySize - - var result = [UInt128]() - result.reserveCapacity(count) - - data.withUnsafeBytes { buffer in - let ptr = buffer.bindMemory(to: UInt64.self) - for i in 0..= prev, "Price at step \(i) should be >= step \(i-1)") - } - } - - @Test - func cumulativeTableIsMonotonicallyIncreasing() { - // Check first 100 entries - for i in 1..<100 { - let prev = DiscreteCurveTables.cumulativeTable[i - 1] - let curr = DiscreteCurveTables.cumulativeTable[i] - #expect(curr >= prev, "Cumulative at step \(i) should be >= step \(i-1)") - } - } - - @Test - func pricingTableMatchesRustValues() { - // Expected values from Rust table.rs (raw u128 scaled by 10^18) - let expectedRaw: [UInt64] = [ - 10000000000000000, // Supply: 0 - 10000877213746469, // Supply: 100 - 10001754504443334, // Supply: 200 - 10002631872097344, // Supply: 300 - 10003509316715251, // Supply: 400 - ] - - for (i, expected) in expectedRaw.enumerated() { - let actual = DiscreteCurveTables.pricingTable[i] - #expect(actual == UInt128(expected), "Mismatch at index \(i)") - } - } - - @Test - func cumulativeTableMatchesRustValues() { - // Expected values from Rust table.rs - let expectedRaw: [UInt64] = [ - 0, // Supply: 0 - 1000000000000000000, // Supply: 100 - 2000087721374646900, // Supply: 200 - 3000263171818980300, // Supply: 300 - 4000526359028714700, // Supply: 400 - ] - - for (i, expected) in expectedRaw.enumerated() { - let actual = DiscreteCurveTables.cumulativeTable[i] - #expect(actual == UInt128(expected), "Mismatch at index \(i)") - } - } - @Test func tableStepSizeIs100() { #expect(DiscreteBondingCurve.stepSize == 100) @@ -1284,76 +1214,6 @@ struct DiscreteRealWorldTests { #expect(str.hasPrefix("231804283"), "String manipulation should preserve value, got: \(str)") } - @Test - func uint128StringParsing() { - // Test the expected value - let expected = "231804283000000000000" - guard let u128 = UInt128(string: expected) else { - Issue.record("Failed to parse UInt128 from: \(expected)") - return - } - - print("Parsed UInt128: high=\(u128.high), low=\(u128.low)") - - // Verify by reconstructing the value - // value = high * 2^64 + low - // 2^64 = 18446744073709551616 - let twoTo64 = BigDecimal("18446744073709551616") - let reconstructed = BigDecimal(String(u128.high)).multiply(twoTo64, testRounding) - .add(BigDecimal(String(u128.low)), testRounding) - - print("Reconstructed: \(reconstructed.asString(.plain))") - #expect(reconstructed.asString(.plain) == expected, "Should reconstruct to original value") - } - - @Test - func fullToScaledU128Simulation() { - // Simulate exactly what toScaledU128 does - let tvl = BigDecimal("231.804283") - - // Step 1: scale factor (10^18) - let scaleFactor = BigDecimal.ten.pow(18, testRounding) - print("Scale factor: \(scaleFactor.asString(.plain))") - - // Step 2: multiply - let scaled = tvl.multiply(scaleFactor, testRounding) - print("Scaled: \(scaled.asString(.plain))") - - // Step 3: round to integer - BUG: Rounding(.towardZero, 0) truncates incorrectly! - let floorRounding0 = Rounding(.towardZero, 0) - let intPart0 = scaled.round(floorRounding0) - print("With precision 0: \(intPart0.asString(.plain))") - - // Try different precisions - let floorRounding21 = Rounding(.towardZero, 21) // 21 significant digits to cover our number - let intPart21 = scaled.round(floorRounding21) - print("With precision 21: \(intPart21.asString(.plain))") - - let floorRounding36 = Rounding(.towardZero, 36) - let intPart36 = scaled.round(floorRounding36) - print("With precision 36: \(intPart36.asString(.plain))") - - // Try using string manipulation: extract integer part from string - let fullStr = scaled.asString(.plain) - var intPartStr = fullStr - if let dotIndex = fullStr.firstIndex(of: ".") { - intPartStr = String(fullStr[..= 12, "high should be >= 12, got: \(u128.high)") - #expect(u128.high <= 13, "high should be <= 13, got: \(u128.high)") - } - @Test func supplyFromTVLForJeffyTVL() { // TVL = 231.804283 USDC = 231804283 quarks @@ -1370,80 +1230,6 @@ struct DiscreteRealWorldTests { } } - @Test - func cumulativeTableMonotonicity() { - // Check steps 195-210 - for i in 195..<210 { - let curr = DiscreteCurveTables.cumulativeTable[i] - let next = DiscreteCurveTables.cumulativeTable[i + 1] - #expect(next > curr, "cumulative[\(i+1)] should be > cumulative[\(i)]") - } - } - - @Test - func cumulativeAtStep230() { - // At step 230 (supply = 23000), cumulative TVL should be around $232 - // (230 steps * ~$1 per step) - let step230 = DiscreteCurveTables.cumulativeTable[230] - - // Convert UInt128 to BigDecimal: value = high * 2^64 + low, then divide by 10^18 - let twoToThe64 = BigDecimal("18446744073709551616") - let scale18 = BigDecimal("1000000000000000000") - let highPart = BigDecimal(String(step230.high)).multiply(twoToThe64, testRounding) - let combined = highPart.add(BigDecimal(String(step230.low)), testRounding) - let step230Decimal = combined.divide(scale18, testRounding) - - print("Cumulative at step 230: \(step230Decimal.asString(.plain))") - - // Should be roughly $230-235 - let step230Double = Double(step230Decimal.asString(.plain))! - #expect(step230Double > 225, "Cumulative at step 230 should be > $225") - #expect(step230Double < 250, "Cumulative at step 230 should be < $250") - } - - @Test - func binarySearchForJeffyTVL() { - // TVL = 231.804283 USDC - let tvl = BigDecimal("231.804283") - let scale18 = BigDecimal("1000000000000000000") - let tvlScaled = tvl.multiply(scale18, testRounding) - - // Convert to UInt128 for comparison using string - var tvlString = tvlScaled.asString(.plain) - // Remove any decimal part - if let dotIndex = tvlString.firstIndex(of: ".") { - tvlString = String(tvlString[.. 220, "Should find step > 220 for TVL $231.80") - #expect(foundStep < 240, "Should find step < 240 for TVL $231.80") - } - @Test func tokensForValueExchangeJeffyScenario() { // Scenario: Exchange $1 CAD at fiatRate 1.38262 @@ -1625,42 +1411,6 @@ struct DiscreteAdditionalCoverageTests { #expect(result != nil, "Should be able to buy at high but valid TVL") } - // MARK: - Cumulative Table Consistency - - @Test - func cumulativeDifferenceMatchesStepCost() { - // For any step i: cumulative[i+1] - cumulative[i] ≈ 100 * price[i] - // Test a few steps to verify table consistency - for step in [0, 10, 100, 1000] { - guard step + 1 < DiscreteCurveTables.cumulativeTable.count else { continue } - - let cumPrev = DiscreteCurveTables.cumulativeTable[step] - let cumNext = DiscreteCurveTables.cumulativeTable[step + 1] - let priceAtStep = DiscreteCurveTables.pricingTable[step] - - // Convert to BigDecimal for comparison - let twoTo64 = BigDecimal("18446744073709551616") - let scale18 = BigDecimal("1000000000000000000") - - // cumDiff = cumNext - cumPrev (in scaled u128) - let cumPrevDecimal = BigDecimal(String(cumPrev.high)).multiply(twoTo64, testRounding) - .add(BigDecimal(String(cumPrev.low)), testRounding) - let cumNextDecimal = BigDecimal(String(cumNext.high)).multiply(twoTo64, testRounding) - .add(BigDecimal(String(cumNext.low)), testRounding) - let cumDiff = cumNextDecimal.subtract(cumPrevDecimal, testRounding) - - // expectedCost = 100 * price (both in scaled u128) - let priceDecimal = BigDecimal(String(priceAtStep.high)).multiply(twoTo64, testRounding) - .add(BigDecimal(String(priceAtStep.low)), testRounding) - let expectedCost = priceDecimal.multiply(BigDecimal(100), testRounding) - - // They should be approximately equal (within small tolerance for rounding) - let ratio = cumDiff.divide(expectedCost, testRounding) - #expect(isApproximatelyEqual(ratio, BigDecimal("1.0"), tolerance: BigDecimal("0.0001")), - "Cumulative difference at step \(step) should equal 100 * price") - } - } - // MARK: - Binary Search Edge Cases @Test From 83cb81b7f872de82002f04b4f2c5fcd38c7bd9c0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 6 Sep 2026 09:21:41 -0400 Subject: [PATCH 2/3] refactor(core): rename SharedDiscreteCurve reference to SharedBondingCurve --- .../Models/DiscreteBondingCurve.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/DiscreteBondingCurve.swift b/FlipcashCore/Sources/FlipcashCore/Models/DiscreteBondingCurve.swift index 494009de9..be74fba66 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/DiscreteBondingCurve.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/DiscreteBondingCurve.swift @@ -19,7 +19,7 @@ import SharedCoreKit /// /// The table-driven pricing math is computed by the shared Kotlin engine /// (`:libs:currency-math:discrete-curve`, exported through `SharedCoreKit`'s -/// `SharedDiscreteCurve`) so both platforms agree exactly -- this type only +/// `SharedBondingCurve`) so both platforms agree exactly -- this type only /// loads the resource tables and converts to/from `BigDecimal` at the boundary. public struct DiscreteBondingCurve: Sendable { @@ -53,7 +53,7 @@ public struct DiscreteBondingCurve: Sendable { // MARK: - Table Loading /// Loads the pricing/cumulative tables into the shared Kotlin engine, once per process. - /// `SharedDiscreteCurve.initialize` itself no-ops on a second call, so re-triggering this + /// `SharedBondingCurve.initialize` itself no-ops on a second call, so re-triggering this /// from another `DiscreteBondingCurve` instance is harmless. private static let tablesLoaded: Void = { guard @@ -64,7 +64,7 @@ public struct DiscreteBondingCurve: Sendable { else { fatalError("Missing discrete curve table resources") } - SharedDiscreteCurve.initialize(pricingTableBytes: pricingData, cumulativeTableBytes: cumulativeData) + SharedBondingCurve.initialize(pricingTableBytes: pricingData, cumulativeTableBytes: cumulativeData) }() // MARK: - Core Methods @@ -78,7 +78,7 @@ public struct DiscreteBondingCurve: Sendable { /// - Returns: Price per token in USDC, or nil if supply exceeds max public func spotPrice(at supply: Int) -> BigDecimal? { _ = Self.tablesLoaded - guard let result = SharedDiscreteCurve.spotPriceAtSupply(supply: Int32(supply)) else { + guard let result = SharedBondingCurve.spotPriceAtSupply(supply: Int32(supply)) else { return nil } return BigDecimal(result) @@ -107,7 +107,7 @@ public struct DiscreteBondingCurve: Sendable { /// - Returns: Total cost in USDC, or nil if purchase would exceed max supply public func tokensToValue(currentSupply: BigDecimal, tokens: BigDecimal) -> BigDecimal? { _ = Self.tablesLoaded - guard let result = SharedDiscreteCurve.tokensToValue( + guard let result = SharedBondingCurve.tokensToValue( currentSupply: currentSupply.asString(.plain), tokens: tokens.asString(.plain) ) else { @@ -127,7 +127,7 @@ public struct DiscreteBondingCurve: Sendable { /// - Returns: Number of tokens that can be purchased, or nil if at max supply public func valueToTokens(currentSupply: Int, value: BigDecimal) -> BigDecimal? { _ = Self.tablesLoaded - guard let result = SharedDiscreteCurve.valueToTokens( + guard let result = SharedBondingCurve.valueToTokens( currentSupply: Int32(currentSupply), value: value.asString(.plain) ) else { @@ -145,7 +145,7 @@ public struct DiscreteBondingCurve: Sendable { /// - Returns: Supply as BigDecimal with fractional tokens private func preciseSupplyFromValue(_ value: BigDecimal) -> BigDecimal { _ = Self.tablesLoaded - return BigDecimal(SharedDiscreteCurve.preciseSupplyFromValue(value: value.asString(.plain))) + return BigDecimal(SharedBondingCurve.preciseSupplyFromValue(value: value.asString(.plain))) } /// Calculate supply from TVL using the cumulative table. @@ -162,7 +162,7 @@ public struct DiscreteBondingCurve: Sendable { /// - Returns: Current supply in whole tokens at the step boundary public func supplyFromTVL(_ tvlQuarks: Int) -> Int? { _ = Self.tablesLoaded - return Int(SharedDiscreteCurve.supplyFromTVL(tvlQuarks: Int64(tvlQuarks))) + return Int(SharedBondingCurve.supplyFromTVL(tvlQuarks: Int64(tvlQuarks))) } /// Low-level primitive backing the high-level `tokensForValueExchange(fiat:fiatRate:supplyQuarks:)` @@ -175,7 +175,7 @@ public struct DiscreteBondingCurve: Sendable { /// (value <= 0, value > currentValue, or the resulting tokens are <= 0) func tokensForValueExchange(currentValue: BigDecimal, value: BigDecimal) -> (tokens: BigDecimal, fx: BigDecimal)? { _ = Self.tablesLoaded - guard let result = SharedDiscreteCurve.tokensForValueExchange( + guard let result = SharedBondingCurve.tokensForValueExchange( currentValue: currentValue.asString(.plain), value: value.asString(.plain) ) else { From 6c2c146263858fea72ca9704880965fa3ce32688 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 15:43:40 -0400 Subject: [PATCH 3/3] chore(shared-core): bump SharedCore SPM pin to 0.6.0 flipcash-shared-core-spm 0.6.0 renames SharedDiscreteCurve to SharedBondingCurve, which DiscreteBondingCurve.swift already expects. Bump the version floor in the three Package.swift manifests and the Xcode project's package reference, then re-resolve so Package.resolved picks up 0.6.0. --- Code.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- CrossPlatformVectors/Package.swift | 2 +- FlipcashCore/Package.swift | 2 +- FlipcashUI/Package.swift | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 59195821b..a7d6db5a3 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -1766,7 +1766,7 @@ repositoryURL = "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/code-payments/flipcash-shared-core-spm"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.5.0; + minimumVersion = 0.6.0; }; }; 9ACE172927E6287C00ACA047 /* XCRemoteSwiftPackageReference "mixpanel-swift" */ = { diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 11431824b..5947de3ea 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "bfbaca6df065c0441a76cea8f5de36ddcc5deb1584f25cf135fc57d082acd454", + "originHash" : "820224161c29b529b257e4e9b853f9fcc4eff96595ff6a5c26e0be6f252927f7", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "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/code-payments/flipcash-shared-core-spm", "state" : { - "revision" : "c83a57e7b90c169d7aa9f50e1c3358ee76f98bb8", - "version" : "0.5.1" + "revision" : "f84edb242946135ca2d9abfc1e105eb73473da10", + "version" : "0.6.0" } }, { diff --git a/CrossPlatformVectors/Package.swift b/CrossPlatformVectors/Package.swift index f58c59f64..797dc194f 100644 --- a/CrossPlatformVectors/Package.swift +++ b/CrossPlatformVectors/Package.swift @@ -21,7 +21,7 @@ let package = Package( .iOS(.v15), ], dependencies: [ - .package(url: "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/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.5.0")), + .package(url: "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/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.6.0")), ], targets: [ .testTarget( diff --git a/FlipcashCore/Package.swift b/FlipcashCore/Package.swift index a983cc036..d75ba0a5a 100644 --- a/FlipcashCore/Package.swift +++ b/FlipcashCore/Package.swift @@ -23,7 +23,7 @@ let package = Package( .package(url: "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/grpc/grpc-swift-nio-transport.git", from: "2.0.0"), .package(url: "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/apple/swift-nio.git", from: "2.81.0"), .package(path: "../FlipcashAPI"), - .package(url: "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/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.5.0")), + .package(url: "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/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.6.0")), ], targets: [ .target( diff --git a/FlipcashUI/Package.swift b/FlipcashUI/Package.swift index 7d8abed7d..41d09cced 100644 --- a/FlipcashUI/Package.swift +++ b/FlipcashUI/Package.swift @@ -19,7 +19,7 @@ let package = Package( .package(url: "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/ekazaev/ChatLayout", from: "2.4.2"), .package(url: "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/ra1028/DifferenceKit", from: "1.3.0"), .package(url: "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/onevcat/Kingfisher", from: "8.3.0"), - .package(url: "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/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.5.0")), + .package(url: "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/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.6.0")), ], targets: [ .target(