Skip to content
Open
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
95 changes: 94 additions & 1 deletion oracledesk-stellar/agents/core/fpmm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
buyWithinLimit,
calcBuy,
calcSell,
calcSellShares,
collateralOutForShares,
grossForShares,
mulDivCeil,
Expand Down Expand Up @@ -222,6 +223,98 @@ test("buyWithinLimit and sellWithinLimit mirror the on-chain boundaries", () =>
assert.equal(buyWithinLimit(1n, 1n, 0n), false);
});

// Known-value fixtures shared bit-for-bit with math.rs's
// `sell_shares_example_matches_model` and model/fpmm_model.py's
// `_check_sell_shares_known_values`. Each entry is
// [rSold, rOther, sharesIn, feeBps, collateralOut, fee, gross,
// newReserveSold, newReserveOther].
const SELL_SHARES_FIXTURES: ReadonlyArray<
readonly [bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint]
> = [
// 1000/1000 pool selling 212 shares at zero fee: gross 100, x = 100.
[1000n, 1000n, 212n, 0n, 100n, 0n, 100n, 1112n, 900n],
// Same pool, 1% fee: fee rounds up, collateral_out floors.
[1000n, 1000n, 212n, 100n, 99n, 1n, 100n, 1112n, 900n],
// Asymmetric reserves.
[1_000_000n, 2_000_000n, 500_000n, 0n, 400_000n, 0n, 400_000n, 1_100_000n, 1_600_000n],
];

test("calcSellShares matches Rust/Python known values", () => {
for (const [rSold, rOther, sharesIn, feeBps, out, fee, gross, ns, no] of SELL_SHARES_FIXTURES) {
const q = calcSellShares(rSold, rOther, sharesIn, feeBps);
assert.equal(q.collateralOut, out, `out r=${rSold}/${rOther} s=${sharesIn} f=${feeBps}`);
assert.equal(q.fee, fee, `fee r=${rSold}/${rOther} s=${sharesIn} f=${feeBps}`);
assert.equal(q.gross, gross, `gross r=${rSold}/${rOther} s=${sharesIn} f=${feeBps}`);
assert.equal(q.newReserveSold, ns, `ns r=${rSold}/${rOther} s=${sharesIn} f=${feeBps}`);
assert.equal(q.newReserveOther, no, `no r=${rSold}/${rOther} s=${sharesIn} f=${feeBps}`);
}
});

test("calcSellShares never decreases k", () => {
let x = 0x9e3779b97f4a7c15n;
const mask = (1n << 64n) - 1n;
const next = () => {
x = (x * 6364136223846793005n + 1442695040888963407n) & mask;
return x >> 33n;
};
let checked = 0;
const trials = 2000;
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 sharesIn = 1n + (next() % (rSold / 2n || 1n));
const feeBps = next() % 300n;
const k0 = rSold * rOther;
let q;
try {
q = calcSellShares(rSold, rOther, sharesIn, feeBps);
} catch {
continue;
}
assert.ok(
q.newReserveSold * q.newReserveOther >= k0,
`k decreased r=${rSold}/${rOther} s=${sharesIn} f=${feeBps}`,
);
checked++;
}
assert.ok(checked > trials / 2, "too many trials skipped, weakens the property check");
});

test("calcSellShares never pays more than calcSell charges for the same shares", () => {
let x = 0xdeadbeefcafebaben;
const mask = (1n << 64n) - 1n;
const next = () => {
x = (x * 6364136223846793005n + 1442695040888963407n) & mask;
return x >> 33n;
};
let checked = 0;
const trials = 1000;
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 sharesIn = 1n + (next() % (rSold / 2n || 1n));
const feeBps = next() % 300n;
let q;
try {
q = calcSellShares(rSold, rOther, sharesIn, feeBps);
} catch {
continue;
}
let back;
try {
back = calcSell(rSold, rOther, q.collateralOut, feeBps);
} catch {
continue;
}
assert.ok(
back.sharesIn <= sharesIn,
`overpaid r=${rSold}/${rOther} s=${sharesIn} f=${feeBps} back=${back.sharesIn}`,
);
checked++;
}
assert.ok(checked > trials / 2, "too many trials skipped, weakens the property check");
});

// ─── collateralForShares (#108) ──────────────────────────────────────────────

function sharesOrZero(rB: bigint, rO: bigint, c: bigint, fee: bigint): bigint {
Expand Down Expand Up @@ -274,4 +367,4 @@ test("collateralForShares rejects invalid input", () => {
assert.throws(() => collateralForShares(0n, 1n, 1n, 0n), MathError);
assert.throws(() => collateralForShares(1n, 1n, 0n, 0n), MathError);
assert.throws(() => collateralForShares(1n, 1n, 1n, 10_000n), MathError);
});
});
81 changes: 63 additions & 18 deletions oracledesk-stellar/agents/core/fpmm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
* 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 `collateralOutForShares` (the off-chain inverse
* for "sell N shares") round-trips through `calcSell`.
*
* 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 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%

Expand Down Expand Up @@ -121,21 +121,68 @@ export function settleValue(kind: Settlement, yes: bigint, no: bigint): bigint {
}

/**
* Integer square root (Newton's method), needed because `sell` takes an
* exact collateral amount rather than a share count (no on-chain sqrt — see
* the docstring on `sell` in contracts/market-core/src/lib.rs). This is
* what makes `grossForShares` computable in BigInt without floating point.
* Integer square root (Newton's method). This mirrors math.rs::isqrt,
* which seeds above the root and converges monotonically down to
* floor(sqrt(n)). The contract uses it inside `calc_sell_shares`; this
* function is also used by the deprecated off-chain inverse below.
*/
export function isqrt(n: bigint): bigint {
if (n < 0n) throw new MathError("isqrt of negative");
if (n < 2n) return n;
let x = n;
let y = (x + 1n) / 2n;
while (y < x) {
// Seed at or above the root via bit length, like the Rust version.
const bits = n.toString(2).length;
const shift = BigInt(Math.floor((bits + 1) / 2));
let x = 1n << shift;
for (;;) {
const y = (x + n / x) >> 1n;
if (y >= x) return x;
x = y;
y = (x + n / x) / 2n;
}
return x;
}

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

/**
* Exact share-to-collateral quote. Mirrors math.rs::calc_sell_shares
* bit-for-bit. Solves (rSold + sharesIn - x)(rother - x) = rSold *
* rOther for the smaller root x, then corrects downward until the
* invariant holds with integer rounding. The correction loop is
* pool-favoring: newSold * newOther >= rSold * rOther, and the
* resulting collateralOut is never more than calcSell would charge
* for the same sharesIn.
*/
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: isqrt rounds down, so x can overshoot
// the real root by at most one. Decrement until the invariant holds.
const k0 = rSold * rOther;
while (x > 0n && (rSold + sharesIn - x) * (rOther - x) < k0) {
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 };
}

/**
Expand Down Expand Up @@ -165,11 +212,9 @@ export function grossForShares(
}

/**
* Estimated `collateral_out` for selling `sharesIn` shares, rounded down
* (pool-favoring) so a real `sell` call with this value and a
* `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` for exact share-to-collateral
* quotes. This off-chain inverse ignores the contract's integer
* correction and is only approximate.
*/
export function collateralOutForShares(
rSold: bigint,
Expand Down Expand Up @@ -283,4 +328,4 @@ export function buyWithinLimit(collateralIn: bigint, sharesOut: bigint, limitBps
export function sellWithinLimit(collateralOut: bigint, sharesIn: bigint, limitBps: bigint): boolean {
if (collateralOut < 0n || sharesIn <= 0n) throw new MathError("invalid input");
return collateralOut * BPS >= sharesIn * limitBps;
}
}
Loading
Loading