Skip to content

Commit 5fa9b00

Browse files
authored
fix(currency-math): correct within-step fractional bonding-curve pricing (#1172)
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.
1 parent 3e091b0 commit 5fa9b00

4 files changed

Lines changed: 226 additions & 2 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
{
2+
"algorithm": "discrete-bonding-curve",
3+
"units": "currentSupply & tokens in whole tokens; spotPrice & value in USDC (18-dp fixed point on-chain)",
4+
"note": "tokensToValue is exact integer arithmetic on the shared u128 tables; ground truth = Rust api/curve.rs.",
5+
"vectors": [
6+
{
7+
"name": "supply=0 tokens=50",
8+
"note": "within single step",
9+
"currentSupply": 0,
10+
"tokens": 50,
11+
"spotPrice": "0.01",
12+
"value": "0.5",
13+
"valueScaled": "500000000000000000"
14+
},
15+
{
16+
"name": "supply=0 tokens=100",
17+
"note": "exact step boundary",
18+
"currentSupply": 0,
19+
"tokens": 100,
20+
"spotPrice": "0.01",
21+
"value": "1",
22+
"valueScaled": "1000000000000000000"
23+
},
24+
{
25+
"name": "supply=50 tokens=50",
26+
"note": "start mid-step, end on boundary (zero end-partial)",
27+
"currentSupply": 50,
28+
"tokens": 50,
29+
"spotPrice": "0.01",
30+
"value": "0.5",
31+
"valueScaled": "500000000000000000"
32+
},
33+
{
34+
"name": "supply=50 tokens=150",
35+
"note": "partial start + full step + boundary end",
36+
"currentSupply": 50,
37+
"tokens": 150,
38+
"spotPrice": "0.01",
39+
"value": "1.5000877213746469",
40+
"valueScaled": "1500087721374646900"
41+
},
42+
{
43+
"name": "supply=75 tokens=350",
44+
"note": "multi-step with both partials (Rust test)",
45+
"currentSupply": 75,
46+
"tokens": 350,
47+
"spotPrice": "0.01",
48+
"value": "3.500614091946595975",
49+
"valueScaled": "3500614091946595975"
50+
},
51+
{
52+
"name": "supply=0 tokens=200",
53+
"note": "two full steps (cumulative subtraction)",
54+
"currentSupply": 0,
55+
"tokens": 200,
56+
"spotPrice": "0.01",
57+
"value": "2.0000877213746469",
58+
"valueScaled": "2000087721374646900"
59+
},
60+
{
61+
"name": "supply=99 tokens=1",
62+
"note": "cross a step boundary buying 1",
63+
"currentSupply": 99,
64+
"tokens": 1,
65+
"spotPrice": "0.01",
66+
"value": "0.01",
67+
"valueScaled": "10000000000000000"
68+
},
69+
{
70+
"name": "supply=100 tokens=1",
71+
"note": "exactly at boundary, buy 1 (single step)",
72+
"currentSupply": 100,
73+
"tokens": 1,
74+
"spotPrice": "0.010000877213746469",
75+
"value": "0.010000877213746469",
76+
"valueScaled": "10000877213746469"
77+
},
78+
{
79+
"name": "supply=1000000 tokens=500",
80+
"note": "high supply: cumulative entries exceed u64 (iOS slow path)",
81+
"currentSupply": 1000000,
82+
"tokens": 500,
83+
"spotPrice": "0.024040991835086708",
84+
"value": "12.0226050113995003",
85+
"valueScaled": "12022605011399500300"
86+
},
87+
{
88+
"name": "supply=20999900 tokens=100",
89+
"note": "final step near max supply (21,000,000)",
90+
"currentSupply": 20999900,
91+
"tokens": 100,
92+
"spotPrice": "999912.28630835324063318",
93+
"value": "99991228.630835324063318",
94+
"valueScaled": "99991228630835324063318000"
95+
}
96+
]
97+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"algorithm": "discrete-bonding-curve-fractional",
3+
"units": "currentSupply & tokens are fractional whole-token decimal strings; value in USDC",
4+
"note": "Exact rational reference. Exercises the sell-path fractional arithmetic + rounding edges.",
5+
"vectors": [
6+
{
7+
"name": "frac within-step",
8+
"note": "fractional tokens inside step 0 (regressed Android)",
9+
"currentSupply": "0",
10+
"tokens": "12.5",
11+
"value": "0.125"
12+
},
13+
{
14+
"name": "frac boundary-cross",
15+
"note": "fractional partial end after a full step",
16+
"currentSupply": "0",
17+
"tokens": "150.5",
18+
"value": "1.5050442992941966845"
19+
},
20+
{
21+
"name": "frac both ends",
22+
"note": "fractional start AND end partial",
23+
"currentSupply": "50.25",
24+
"tokens": "100.5",
25+
"value": "1.00504451859763330175"
26+
},
27+
{
28+
"name": "sell-path multi-step",
29+
"note": "value(0..new_supply): fractional, many steps",
30+
"currentSupply": "0",
31+
"tokens": "12345.6789012345",
32+
"value": "124.122252404322416922567040156"
33+
},
34+
{
35+
"name": "high-supply fractional",
36+
"note": "fractional crossing a boundary at high price",
37+
"currentSupply": "999950.123456789",
38+
"tokens": "100.987654321",
39+
"value": "2.427738197118423368616824456"
40+
},
41+
{
42+
"name": "one-quark token",
43+
"note": "1 token-quark (10^-10): within-step, sub-micro",
44+
"currentSupply": "0",
45+
"tokens": "0.0000000001",
46+
"value": "0.000000000001"
47+
}
48+
]
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.flipcash.libs.currency.math.internal.curves
2+
3+
import androidx.test.ext.junit.runners.AndroidJUnit4
4+
import androidx.test.platform.app.InstrumentationRegistry
5+
import com.flipcash.libs.currency.math.internal.loader.AndroidTableLoader
6+
import kotlinx.coroutines.runBlocking
7+
import org.json.JSONObject
8+
import org.junit.Assert.assertEquals
9+
import org.junit.Assert.assertTrue
10+
import org.junit.Test
11+
import org.junit.runner.RunWith
12+
import java.math.BigDecimal
13+
14+
/**
15+
* GATE: this repo's discrete bonding curve must reproduce the canonical cross-platform fixtures
16+
* exactly. The iOS repo asserts the identical fixtures — matching on both sides guarantees the apps
17+
* price trades identically (a divergence here = one platform computing a different cost/amount than
18+
* the chain expects). Ground truth = the on-chain Rust curve; both apps load the same u128 tables.
19+
*
20+
* Instrumented: the curve loads its binary tables from assets via AndroidTableLoader(Context).
21+
* Fixture synced from `code/test-vectors/`.
22+
*/
23+
@RunWith(AndroidJUnit4::class)
24+
class DiscreteBondingCurveVectorTest {
25+
26+
@Test
27+
fun curve_matches_canonical_vectors() = runBlocking {
28+
val instrumentation = InstrumentationRegistry.getInstrumentation()
29+
DiscreteBondingCurve.initialize(AndroidTableLoader(instrumentation.targetContext)) // loads *.bin
30+
val curve = DiscreteBondingCurve.getOrThrow()
31+
32+
val json = instrumentation.context.assets.open("curve.json").bufferedReader().use { it.readText() }
33+
val vectors = JSONObject(json).getJSONArray("vectors")
34+
assertTrue("no vectors loaded", vectors.length() > 0)
35+
36+
for (i in 0 until vectors.length()) {
37+
val v = vectors.getJSONObject(i)
38+
val name = v.getString("name")
39+
val supply = BigDecimal(v.getInt("currentSupply"))
40+
val tokens = BigDecimal(v.getInt("tokens"))
41+
42+
val spot = curve.spotPriceAtSupply(supply).getOrThrow()
43+
assertEquals("spotPrice mismatch for $name", 0, BigDecimal(v.getString("spotPrice")).compareTo(spot))
44+
45+
val value = curve.tokensToValue(supply, tokens).getOrThrow()
46+
assertEquals("tokensToValue mismatch for $name", 0, BigDecimal(v.getString("value")).compareTo(value))
47+
}
48+
}
49+
50+
/** Fractional (sell-path) + rounding-tie cases: fractional supply/tokens via BigDecimal — the
51+
* residual divergence risk (iOS rounding-context subtraction vs Android exact subtract). */
52+
/** Fractional (sell-path) + rounding-tie cases: fractional supply/tokens via BigDecimal — the
53+
* residual divergence risk (iOS rounding-context subtraction vs Android exact subtract). */
54+
@Test
55+
fun curve_matches_fractional_vectors() = runBlocking {
56+
val instrumentation = InstrumentationRegistry.getInstrumentation()
57+
DiscreteBondingCurve.initialize(AndroidTableLoader(instrumentation.targetContext))
58+
val curve = DiscreteBondingCurve.getOrThrow()
59+
60+
val json = instrumentation.context.assets.open("curve_fractional.json").bufferedReader().use { it.readText() }
61+
val vectors = JSONObject(json).getJSONArray("vectors")
62+
assertTrue("no vectors loaded", vectors.length() > 0)
63+
64+
for (i in 0 until vectors.length()) {
65+
val v = vectors.getJSONObject(i)
66+
val name = v.getString("name")
67+
val supply = BigDecimal(v.getString("currentSupply"))
68+
val tokens = BigDecimal(v.getString("tokens"))
69+
70+
val value = curve.tokensToValue(supply, tokens).getOrThrow()
71+
assertEquals("tokensToValue mismatch for $name", 0, BigDecimal(v.getString("value")).compareTo(value))
72+
}
73+
}
74+
}

libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurve.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ internal class DiscreteBondingCurve private constructor(
9191
): Result<BigDecimal> = runCatching {
9292
require(tokens.signum() >= 0) { "Tokens to sell must be non-negative" }
9393

94-
if (tokens == BigDecimal.ZERO) return@runCatching BigDecimal.ZERO
94+
if (tokens.signum() == 0) return@runCatching BigDecimal.ZERO
9595

9696
val endSupply = currentSupply + tokens
9797
val startStep = currentSupply.divideToIntegralValue(stepSize.toBigDecimal())
@@ -112,7 +112,11 @@ internal class DiscreteBondingCurve private constructor(
112112
val startPrice = pricingTable[startStep.toInt()]
113113
val startCost = tokensInStartStep.multiplyWithHighPrecision(startPrice)
114114

115-
if (startStep == endStep) {
115+
// Compare numerically, NOT with `==`: BigDecimal.equals is scale-sensitive, so for a
116+
// within-step FRACTIONAL purchase startStep ("0", scale 0) and endStep ("0.0", scale 1) are
117+
// equal in value but `==`-unequal — which wrongly fell through to the multi-step path and
118+
// produced a negative cost (e.g. tokensToValue(0, 12.5) = -0.750 instead of 0.125).
119+
if (startStep.compareTo(endStep) == 0) {
116120
return@runCatching startCost
117121
}
118122

0 commit comments

Comments
 (0)