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
97 changes: 97 additions & 0 deletions libs/currency-math/src/androidTest/assets/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"
}
]
}
49 changes: 49 additions & 0 deletions libs/currency-math/src/androidTest/assets/curve_fractional.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ internal class DiscreteBondingCurve private constructor(
): Result<BigDecimal> = 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())
Expand All @@ -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
}

Expand Down
Loading