Skip to content
Closed
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
67 changes: 65 additions & 2 deletions oracledesk-stellar/agents/core/fpmm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BPS,
calcBuy,
calcSell,
calcSellShares,
collateralOutForShares,
priceYesBps,
seedReserves,
Expand All @@ -28,6 +29,41 @@ test("calcSell matches the reference example", () => {
assert.equal(q.newReserveOther, 900n);
});

// Pinned values shared with math.rs's `sell_shares_example_matches_model`
// and model/fpmm_model.py's `_check_sell_shares_known_values`.
test("calcSellShares matches Rust/Python known values", () => {
// 1000/1000 pool, sell 212 shares, no fee.
const q = calcSellShares(1000n, 1000n, 212n, 0n);
assert.equal(q.gross, 100n);
assert.equal(q.fee, 0n);
assert.equal(q.collateralOut, 100n);
assert.equal(q.newReserveSold, 1112n);
assert.equal(q.newReserveOther, 900n);

// Same pool with a 2% (200 bps) fee: fee rounds up, so the trader
// gets floor(gross * 9800 / 10000).
const withFee = calcSellShares(1000n, 1000n, 212n, 200n);
assert.equal(withFee.gross, 100n);
assert.equal(withFee.fee, 2n);
assert.equal(withFee.collateralOut, 98n);
});

// Same known values as the Python fixture table, so a porting mistake
// in the correction loop shows up as a diff here.
test("calcSellShares fixture table", () => {
const cases: Array<[bigint, bigint, bigint, bigint, number]> = [
[1000n, 1000n, 212n, 100n, 0],
[1000n, 1000n, 1n, 0n, 0],
[10000n, 10000n, 1000n, 952n, 0],
[100000n, 500000n, 10000n, 9505n, 0],
[100000n, 500000n, 10000n, 9315n, 200],
];
for (const [rY, rN, shares, expectedOut, feeBps] of cases) {
const q = calcSellShares(rY, rN, shares, BigInt(feeBps));
assert.equal(q.collateralOut, expectedOut, `rY=${rY} rN=${rN} shares=${shares} fee=${feeBps}`);
}
});

test("seedReserves opens at the requested probability", () => {
for (const p of [100n, 2500n, 5000n, 7300n, 9900n]) {
const s = seedReserves(10_000_000_000n, p);
Expand Down Expand Up @@ -64,7 +100,34 @@ test("k never decreases and a round trip never profits", () => {
}
});

test("void settlement never overpays", () => {
test("calcSellShares never decreases k", () => {
// Same LCG as the calcBuy property test above, so the two suites
// explore a comparable input space.
let x = 0x9e3779b97f4a7c15n;
const mask = (1n << 64n) - 1n;
const next = () => {
x = (x * 6364136223846793005n + 1442695040888963407n) & mask;
return x >> 33n;
};
for (let i = 0; i < 2000; i++) {
const ry = 1_000_000n + (next() % 1_000_000_000n);
const rn = 1_000_000n + (next() % 1_000_000_000n);
const shares = 1n + (next() % ((ry < rn ? ry : rn) / 2n));
const fee = BigInt(next() % 300n);
const k0 = ry * rn;
try {
const q = calcSellShares(ry, rn, shares, fee);
assert.ok(
q.newReserveSold * q.newReserveOther >= k0,
`knew < k0 at i=${i}`,
);
} catch {
// insufficient liquidity to sell that many shares is fine
}
}
});

test("settlement never overpays", () => {
assert.equal(settleValue("void", 3n, 4n), 3n);
assert.ok(settleValue("void", 5n, 5n) <= 5n);
});
Expand All @@ -82,7 +145,7 @@ test("collateralOutForShares round-trips through calcSell within one unit of rou
const trials = 500;
for (let i = 0; i < trials; i++) {
const rSold = 1_000_000n + (next() % 1_000_000_000n);
const rOther = 1_000_000n + (next() % 1_000_000_000n);
const rOther = 1_000_000_000n + (next() % 1_000_000_000n);
const feeBps = next() % 300n;
const collateralOut = 1n + (next() % (rOther / 4n || 1n));
let forward;
Expand Down
64 changes: 55 additions & 9 deletions oracledesk-stellar/agents/core/fpmm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@
* TypeScript/BigInt mirror of contracts/market-core/src/math.rs and
* model/fpmm_model.py. Kept in lockstep with both by hand; fpmm.test.ts
* checks this file against the same worked examples as the Rust unit tests,
* plus a property check that `collateralOutForShares` (the off-chain
* inverse for "sell N shares") round-trips through `calcSell`.
* plus a property check that `calcSellShares` matches the on-chain
* `sell_shares` exactly.
*
* All amounts are non-negative bigints in collateral base units (USDC on
* Stellar = 7 decimals). Rounding always favours the pool, matching the
* contract.
*/

export const BPS = 10_000n;
export const MAX_SETS = 1_000_000_000_000_000n; // 1e15 = 100M USDC (7 dp)
export const MIN_PROB_BPS = 100n; // 1%
export const MAX_PROB_BPS = 9_900n; // 99%
export const BPS = 10_000n
export const MAX_SETS = 1_000_000_000_000_000_000n // 1e15 = 100M USDC (7 dp)
export const MIN_PROB_BPS = 100n // 1%
export const MAX_PROB_BPS = 9_900n // 99%

export class MathError extends Error {}

Expand Down Expand Up @@ -76,9 +76,9 @@ export function calcBuy(
): BuyQuote {
if (collateralIn <= 0n || rBought <= 0n || rOther <= 0n) throw new MathError("invalid input");
const fee = mulDivCeil(collateralIn, feeBps, BPS);
const net = collateralIn - fee;
if (net <= 0n) throw new MathError("invalid input");
const newOther = rOther + net;
const net = collateralIn - fee;
if (net <= 0n) throw new MathError("invalid input");
const newOther = rOther + net;
const newBought = mulDivCeil(rBought, rOther, newOther);
const sharesOut = rBought + net - newBought;
if (sharesOut <= 0n) throw new MathError("invalid input");
Expand Down Expand Up @@ -170,6 +170,9 @@ export function grossForShares(
* `max_shares_in` slippage bound of `sharesIn` (plus your own safety
* margin — see docs/frontend-integration.md) should not need more shares
* than intended.
*
* @deprecated Use `calcSellShares` instead, which matches the on-chain
* `sell_shares` exactly (including the pool-favoring correction loop).
*/
export function collateralOutForShares(
rSold: bigint,
Expand All @@ -180,3 +183,46 @@ export function collateralOutForShares(
const gross = grossForShares(rSold, rOther, sharesIn, feeBps);
return mulDivFloor(gross, BPS - feeBps, BPS);
}

export interface SellSharesQuote {
collateralOut: bigint;
fee: bigint;
gross: bigint;
newReserveSold: bigint;
newReserveOther: bigint;
}

/**
* Exact on-chain equivalent of `calc_sell_shares` in contracts/market-core/src/math.rs
* and model/fpmm_model.py. Solves the constant-product invariant for the
* gross sets to burn given an exact `sharesIn`, then applies the pool-favoring
* correction loop so that `newSold * newOther >= rSold * rOther`. Returns the
* collateral amount the trader receives, with the fee rounded up.
*/
export function calcSellShares(
rSold: bigint,
rOther: bigint,
sharesIn: bigint,
feeBps: bigint,
): SellSharesQuote {
if (sharesIn <= 0n || rSold <= 0n || rOther <= 0n || feeBps >= BPS) {
throw new MathError("invalid input");
}
const b = rSold + sharesIn + rOther;
const disc = b * b - 4n * rOther * sharesIn;
if (disc < 0n) throw new MathError("insufficient liquidity");
let x = (b - isqrt(disc)) / 2n;
// Pool-favoring correction: the floored square root can overshoot the
// real root by at most one, and the discrete check below can need at
// most one further decrement. Never decrement below the true root.
while (x > 0n && (rSold + sharesIn - x) * (rOther - x) < rSold * rOther) {
x -= 1n;
}
if (x <= 0n || x >= rOther) throw new MathError("insufficient liquidity");
const collateralOut = mulDivFloor(x, BPS - feeBps, BPS);
if (collateralOut <= 0n) throw new MathError("invalid input");
const fee = x - collateralOut;
const newSold = rSold + sharesIn - x;
const newOther = rOther - x;
return { collateralOut, fee, gross: x, newReserveSold: newSold, newReserveOther: newOther };
}
Loading
Loading