From d62bd41cf7733434ee4a42dadfe28ac0ff312643 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 29 Jul 2026 12:26:35 -0400 Subject: [PATCH] fix(currency-math): correct within-step fractional bonding-curve pricing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiscreteBondingCurve.tokensToValue returned a negative value for a within-step purchase with a fractional token amount — e.g. tokensToValue(0, 12.5) yielded -0.750 instead of 0.125. Root cause: the `startStep == endStep` short-circuit used BigDecimal `==` (scale-sensitive equals). For a fractional within-step purchase, startStep ("0", scale 0) and endStep ("0.0", scale 1) are value-equal but `==`-unequal, so the code fell through to the multi-step branch and computed middleCost = cumulative[0] - cumulative[1] = -1.0. Whole-token inputs share scale 0, so `==` happened to work — which is why the bug stayed hidden. Fix: compare with compareTo; also harden the sibling `tokens == ZERO` check to signum() (same scale-sensitivity class). Adds DiscreteBondingCurveVectorTest, asserting the curve against canonical vectors derived from the on-chain Rust curve (both apps load identical tables), including the fractional cases that regressed. Verified on device. --- .../src/androidTest/assets/curve.json | 97 +++++++++++++++++++ .../androidTest/assets/curve_fractional.json | 49 ++++++++++ .../curves/DiscreteBondingCurveVectorTest.kt | 74 ++++++++++++++ .../internal/curves/DiscreteBondingCurve.kt | 8 +- 4 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 libs/currency-math/src/androidTest/assets/curve.json create mode 100644 libs/currency-math/src/androidTest/assets/curve_fractional.json create mode 100644 libs/currency-math/src/androidTest/java/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurveVectorTest.kt diff --git a/libs/currency-math/src/androidTest/assets/curve.json b/libs/currency-math/src/androidTest/assets/curve.json new file mode 100644 index 0000000000..841ddf1ae2 --- /dev/null +++ b/libs/currency-math/src/androidTest/assets/curve.json @@ -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" + } + ] +} diff --git a/libs/currency-math/src/androidTest/assets/curve_fractional.json b/libs/currency-math/src/androidTest/assets/curve_fractional.json new file mode 100644 index 0000000000..e4507cd960 --- /dev/null +++ b/libs/currency-math/src/androidTest/assets/curve_fractional.json @@ -0,0 +1,49 @@ +{ + "algorithm": "discrete-bonding-curve-fractional", + "units": "currentSupply & tokens are fractional whole-token decimal strings; value in USDC", + "note": "Exact rational reference. Exercises the sell-path fractional arithmetic + rounding edges.", + "vectors": [ + { + "name": "frac within-step", + "note": "fractional tokens inside step 0 (regressed Android)", + "currentSupply": "0", + "tokens": "12.5", + "value": "0.125" + }, + { + "name": "frac boundary-cross", + "note": "fractional partial end after a full step", + "currentSupply": "0", + "tokens": "150.5", + "value": "1.5050442992941966845" + }, + { + "name": "frac both ends", + "note": "fractional start AND end partial", + "currentSupply": "50.25", + "tokens": "100.5", + "value": "1.00504451859763330175" + }, + { + "name": "sell-path multi-step", + "note": "value(0..new_supply): fractional, many steps", + "currentSupply": "0", + "tokens": "12345.6789012345", + "value": "124.122252404322416922567040156" + }, + { + "name": "high-supply fractional", + "note": "fractional crossing a boundary at high price", + "currentSupply": "999950.123456789", + "tokens": "100.987654321", + "value": "2.427738197118423368616824456" + }, + { + "name": "one-quark token", + "note": "1 token-quark (10^-10): within-step, sub-micro", + "currentSupply": "0", + "tokens": "0.0000000001", + "value": "0.000000000001" + } + ] +} diff --git a/libs/currency-math/src/androidTest/java/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurveVectorTest.kt b/libs/currency-math/src/androidTest/java/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurveVectorTest.kt new file mode 100644 index 0000000000..100aed0a44 --- /dev/null +++ b/libs/currency-math/src/androidTest/java/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurveVectorTest.kt @@ -0,0 +1,74 @@ +package com.flipcash.libs.currency.math.internal.curves + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.flipcash.libs.currency.math.internal.loader.AndroidTableLoader +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.math.BigDecimal + +/** + * GATE: this repo's discrete bonding curve must reproduce the canonical cross-platform fixtures + * exactly. The iOS repo asserts the identical fixtures — matching on both sides guarantees the apps + * price trades identically (a divergence here = one platform computing a different cost/amount than + * the chain expects). Ground truth = the on-chain Rust curve; both apps load the same u128 tables. + * + * Instrumented: the curve loads its binary tables from assets via AndroidTableLoader(Context). + * Fixture synced from `code/test-vectors/`. + */ +@RunWith(AndroidJUnit4::class) +class DiscreteBondingCurveVectorTest { + + @Test + fun curve_matches_canonical_vectors() = runBlocking { + val instrumentation = InstrumentationRegistry.getInstrumentation() + DiscreteBondingCurve.initialize(AndroidTableLoader(instrumentation.targetContext)) // loads *.bin + val curve = DiscreteBondingCurve.getOrThrow() + + val json = instrumentation.context.assets.open("curve.json").bufferedReader().use { it.readText() } + val vectors = JSONObject(json).getJSONArray("vectors") + assertTrue("no vectors loaded", vectors.length() > 0) + + for (i in 0 until vectors.length()) { + val v = vectors.getJSONObject(i) + val name = v.getString("name") + val supply = BigDecimal(v.getInt("currentSupply")) + val tokens = BigDecimal(v.getInt("tokens")) + + val spot = curve.spotPriceAtSupply(supply).getOrThrow() + assertEquals("spotPrice mismatch for $name", 0, BigDecimal(v.getString("spotPrice")).compareTo(spot)) + + val value = curve.tokensToValue(supply, tokens).getOrThrow() + assertEquals("tokensToValue mismatch for $name", 0, BigDecimal(v.getString("value")).compareTo(value)) + } + } + + /** Fractional (sell-path) + rounding-tie cases: fractional supply/tokens via BigDecimal — the + * residual divergence risk (iOS rounding-context subtraction vs Android exact subtract). */ + /** Fractional (sell-path) + rounding-tie cases: fractional supply/tokens via BigDecimal — the + * residual divergence risk (iOS rounding-context subtraction vs Android exact subtract). */ + @Test + fun curve_matches_fractional_vectors() = runBlocking { + val instrumentation = InstrumentationRegistry.getInstrumentation() + DiscreteBondingCurve.initialize(AndroidTableLoader(instrumentation.targetContext)) + val curve = DiscreteBondingCurve.getOrThrow() + + val json = instrumentation.context.assets.open("curve_fractional.json").bufferedReader().use { it.readText() } + val vectors = JSONObject(json).getJSONArray("vectors") + assertTrue("no vectors loaded", vectors.length() > 0) + + for (i in 0 until vectors.length()) { + val v = vectors.getJSONObject(i) + val name = v.getString("name") + val supply = BigDecimal(v.getString("currentSupply")) + val tokens = BigDecimal(v.getString("tokens")) + + val value = curve.tokensToValue(supply, tokens).getOrThrow() + assertEquals("tokensToValue mismatch for $name", 0, BigDecimal(v.getString("value")).compareTo(value)) + } + } +} diff --git a/libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurve.kt b/libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurve.kt index 80046a1973..b3bde9c666 100644 --- a/libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurve.kt +++ b/libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurve.kt @@ -91,7 +91,7 @@ internal class DiscreteBondingCurve private constructor( ): Result = runCatching { require(tokens.signum() >= 0) { "Tokens to sell must be non-negative" } - if (tokens == BigDecimal.ZERO) return@runCatching BigDecimal.ZERO + if (tokens.signum() == 0) return@runCatching BigDecimal.ZERO val endSupply = currentSupply + tokens val startStep = currentSupply.divideToIntegralValue(stepSize.toBigDecimal()) @@ -112,7 +112,11 @@ internal class DiscreteBondingCurve private constructor( val startPrice = pricingTable[startStep.toInt()] val startCost = tokensInStartStep.multiplyWithHighPrecision(startPrice) - if (startStep == endStep) { + // Compare numerically, NOT with `==`: BigDecimal.equals is scale-sensitive, so for a + // within-step FRACTIONAL purchase startStep ("0", scale 0) and endStep ("0.0", scale 1) are + // equal in value but `==`-unequal — which wrongly fell through to the multi-step path and + // produced a negative cost (e.g. tokensToValue(0, 12.5) = -0.750 instead of 0.125). + if (startStep.compareTo(endStep) == 0) { return@runCatching startCost }