Created
August 18, 2025 03:00
-
-
Save mikekoro/7f5ad5a0392a58b4f0d453462889252e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { dropsToXrp } from "xrpl"; | |
type IOU = { currency: string; issuer: string }; | |
type Asset = "XRP" | IOU; | |
type QuoteParams = { | |
side: "buy" | "sell"; | |
base: Asset; // asset you get on buy / sell on sell | |
quote: Asset; // asset you pay on buy / receive on sell | |
mode: "from" | "to"; // which field user typed | |
amount: number; // value in that field | |
}; | |
type QuoteResult = { | |
side: "buy" | "sell"; | |
base: Asset; | |
quote: Asset; | |
fromAmount: number; // amount in FROM asset (what user typed or computed) | |
toAmount: number; // amount in TO asset (computed or what user typed) | |
fromOrderbook: number; // portion sourced via OB (in QUOTE units for buy; in QUOTE units for sell) | |
fromAMM: number; // portion via AMM (same units as above) | |
blendedPriceQuotePerBase: number; // QUOTE per BASE | |
}; | |
type XRPLAmount = string | { currency: string; issuer?: string; value: string }; | |
const isXRP = (a: Asset): a is "XRP" => a === "XRP"; | |
const amtToFloat = (amt: XRPLAmount) => | |
typeof amt === "string" ? Number(dropsToXrp(amt)) : Number(amt.value); | |
async function transferRateOf(client: any, a: Asset): Promise<number> { | |
if (isXRP(a)) return 1; | |
const res = await client.request({ | |
command: "account_info", | |
account: a.issuer, | |
ledger_index: "current", | |
}); | |
const u = res.result.account_data.TransferRate; | |
return u ? Number(u) / 1_000_000_000 : 1; | |
} | |
async function fetchAMM(client: any, base: Asset, quote: Asset) { | |
const { result } = await client.request({ | |
command: "amm_info", | |
asset: isXRP(base) ? "XRP" : base, | |
asset2: isXRP(quote) ? "XRP" : quote, | |
ledger_index: "current", | |
}); | |
const x = amtToFloat(result.amm.amount); // base reserve | |
const y = amtToFloat(result.amm.amount2); // quote reserve | |
const feeUnits = result.amm.trading_fee ?? 0; | |
return { x, y, feeUnits }; | |
} | |
function normalizeAsks(offers: any[]) { | |
// maker sells BASE for QUOTE → taker_pays = QUOTE, taker_gets = BASE | |
return (offers || []) | |
.map((o: any) => { | |
const qtyBase = amtToFloat(o.taker_gets); | |
const paysQuote = amtToFloat(o.taker_pays); | |
const price = paysQuote / qtyBase; // QUOTE per BASE | |
return { price, qtyBase }; | |
}) | |
.filter((x: any) => x.qtyBase > 0 && isFinite(x.price)) | |
.sort((a: any, b: any) => a.price - b.price); | |
} | |
function normalizeBids(offers: any[]) { | |
// maker buys BASE paying QUOTE → taker_pays = BASE, taker_gets = QUOTE | |
return (offers || []) | |
.map((o: any) => { | |
const paysBase = amtToFloat(o.taker_pays); | |
const getsQuote = amtToFloat(o.taker_gets); | |
const price = getsQuote / paysBase; // QUOTE per BASE | |
const qtyBase = paysBase; | |
return { price, qtyBase }; | |
}) | |
.filter((x: any) => x.qtyBase > 0 && isFinite(x.price)) | |
.sort((a: any, b: any) => b.price - a.price); | |
} | |
/** ---------- AMM math (handles fee + transfer rates) ---------- */ | |
// BUY cost for q BASE out (QUOTE units) | |
function ammBuyCost(q: number, x: number, y: number, feeUnits: number, trIn: number, trOut: number) { | |
if (q <= 0) return 0; | |
const f = feeUnits / 100000; | |
const outGross = trOut * q; | |
if (outGross >= x) throw new Error("tokenOut >= pool reserve"); | |
const inEff = (y * outGross) / (x - outGross); | |
return inEff / (trIn * (1 - f)); | |
} | |
// BUY marginal price at cumulative q (QUOTE per BASE) | |
function ammBuyMarginal(q: number, x: number, y: number, feeUnits: number, trIn: number, trOut: number) { | |
const f = feeUnits / 100000; | |
const denom = x - trOut * q; | |
if (denom <= 0) return Infinity; | |
return (y * trOut * x) / (trIn * (1 - f) * denom * denom); | |
} | |
// Given extra budget ΔQuote, get Δq BASE using closed form (from q0) | |
function ammBuyDeltaQForBudget(q0: number, dQuote: number, x: number, y: number, feeUnits: number, trIn: number, trOut: number) { | |
if (dQuote <= 0) return 0; | |
const f = feeUnits / 100000; | |
const A = y / (trIn * (1 - f)); | |
const B = x / trOut; | |
const F0 = q0 / (B - q0); | |
const F1 = F0 + dQuote / A; | |
return (B * F1) / (1 + F1) - q0; | |
} | |
function solveBuyQForMarginalEqual(pTarget: number, q0: number, x: number, y: number, feeUnits: number, trIn: number, trOut: number) { | |
const f = feeUnits / 100000; | |
const s = Math.sqrt((y * trOut * x) / (trIn * (1 - f) * pTarget)); | |
const qStar = (x - s) / trOut; | |
return Math.max(q0, qStar); | |
} | |
// SELL proceeds for q BASE in (QUOTE units) | |
function ammSellOut(q: number, x: number, y: number, feeUnits: number, trIn: number, trOut: number) { | |
if (q <= 0) return 0; | |
const f = feeUnits / 100000; | |
const k = trIn * (1 - f); | |
const outGross = (y * (k * q)) / (x + k * q); | |
return outGross / trOut; | |
} | |
// SELL marginal price at cumulative q (QUOTE per BASE) | |
function ammSellMarginal(q: number, x: number, y: number, feeUnits: number, trIn: number, trOut: number) { | |
const f = feeUnits / 100000; | |
const k = trIn * (1 - f); | |
const denom = x + k * q; | |
return (y * k * x) / (trOut * denom * denom); | |
} | |
// Given extra proceeds ΔQuote, get Δq BASE using closed form (from q0) | |
function ammSellDeltaQForProceeds(q0: number, dQuote: number, x: number, y: number, feeUnits: number, trIn: number, trOut: number) { | |
if (dQuote <= 0) return 0; | |
const f = feeUnits / 100000; | |
const k = trIn * (1 - f); | |
const C = y / trOut; | |
const F0 = (k * q0) / (x + k * q0); | |
const F1 = F0 + dQuote / C; // target fraction of AMM reserve paid out | |
if (F1 >= 1) return Infinity; // asymptote guard | |
return ( (x / k) * (F1 / (1 - F1)) ) - q0; | |
} | |
/** ---------- Dual-input SOR ---------- */ | |
export async function quoteFlexible(client: any, p: QuoteParams): Promise<QuoteResult> { | |
const { side, base, quote, mode, amount } = p; | |
if (amount <= 0) throw new Error("amount must be > 0"); | |
// AMM + rates | |
const { x, y, feeUnits } = await fetchAMM(client, base, quote); | |
const trBase = await transferRateOf(client, base); | |
const trQuote = await transferRateOf(client, quote); | |
const trIn = side === "buy" ? trQuote : trBase; | |
const trOut = side === "buy" ? trBase : trQuote; | |
let fromOB = 0; // QUOTE units (spent on buy / received on sell) | |
let fromAMM = 0; | |
let qAMM = 0; // cumulative BASE routed to/from AMM | |
let remainingBase: number; // BASE still needed (buy 'to' mode / sell 'from' mode) | |
let remainingQuote: number; // QUOTE budget (buy 'from' mode) or target proceeds (sell 'to' mode) | |
let gotBase = 0; // BASE acquired (buy 'from' mode) | |
let spentQuote = 0; // QUOTE spent (buy 'to' mode) | |
if (side === "buy") { | |
// Get asks | |
const book = await client.request({ | |
command: "book_offers", | |
taker_pays: isXRP(quote) ? "XRP" : quote, | |
taker_gets: isXRP(base) ? "XRP" : base, | |
ledger_index: "current", | |
limit: 200, | |
}); | |
const asks = normalizeAsks(book.result.offers); | |
if (mode === "to") { | |
// want exact BASE out | |
remainingBase = amount; | |
for (const lvl of asks) { | |
if (remainingBase <= 0) break; | |
const pOB = lvl.price; | |
// Route to AMM while it's cheaper than this ask | |
const pAM = ammBuyMarginal(qAMM, x, y, feeUnits, trIn, trOut); | |
if (pAM < pOB) { | |
const qStar = solveBuyQForMarginalEqual(pOB, qAMM, x, y, feeUnits, trIn, trOut); | |
const take = Math.min(remainingBase, Math.max(0, qStar - qAMM)); | |
if (take > 0) { | |
const before = ammBuyCost(qAMM, x, y, feeUnits, trIn, trOut); | |
const after = ammBuyCost(qAMM + take, x, y, feeUnits, trIn, trOut); | |
const cost = after - before; | |
fromAMM += cost; spentQuote += cost; qAMM += take; remainingBase -= take; | |
} | |
} | |
if (remainingBase <= 0) break; | |
// Take from OB at this level | |
const takeOB = Math.min(remainingBase, lvl.qtyBase); | |
if (takeOB > 0) { | |
const cost = takeOB * pOB; | |
fromOB += cost; spentQuote += cost; remainingBase -= takeOB; | |
} | |
} | |
// Finish on AMM if needed | |
if (remainingBase > 0) { | |
const before = ammBuyCost(qAMM, x, y, feeUnits, trIn, trOut); | |
const after = ammBuyCost(qAMM + remainingBase, x, y, feeUnits, trIn, trOut); | |
const cost = after - before; | |
fromAMM += cost; spentQuote += cost; qAMM += remainingBase; remainingBase = 0; | |
} | |
return { | |
side, base, quote, | |
fromAmount: spentQuote, | |
toAmount: amount, | |
fromOrderbook: fromOB, | |
fromAMM, | |
blendedPriceQuotePerBase: (fromOB + fromAMM) / amount, | |
}; | |
} else { | |
// mode === 'from' → budget in QUOTE, get BASE out | |
remainingQuote = amount; | |
for (const lvl of asks) { | |
if (remainingQuote <= 0) break; | |
const pOB = lvl.price; | |
// While AMM cheaper than this level, spend on AMM up to equality or budget | |
const pAM = ammBuyMarginal(qAMM, x, y, feeUnits, trIn, trOut); | |
if (pAM < pOB) { | |
// Spend until AMM marginal reaches pOB | |
const qStar = solveBuyQForMarginalEqual(pOB, qAMM, x, y, feeUnits, trIn, trOut); | |
const needToEqual = ammBuyCost(qStar, x, y, feeUnits, trIn, trOut) - ammBuyCost(qAMM, x, y, feeUnits, trIn, trOut); | |
if (remainingQuote <= needToEqual) { | |
const dq = ammBuyDeltaQForBudget(qAMM, remainingQuote, x, y, feeUnits, trIn, trOut); | |
const before = ammBuyCost(qAMM, x, y, feeUnits, trIn, trOut); | |
const after = ammBuyCost(qAMM + dq, x, y, feeUnits, trIn, trOut); | |
const spent = after - before; | |
fromAMM += spent; gotBase += dq; qAMM += dq; remainingQuote = 0; | |
break; | |
} else { | |
const dq = Math.max(0, qStar - qAMM); | |
const before = ammBuyCost(qAMM, x, y, feeUnits, trIn, trOut); | |
const after = ammBuyCost(qAMM + dq, x, y, feeUnits, trIn, trOut); | |
const spent = after - before; | |
fromAMM += spent; gotBase += dq; qAMM += dq; remainingQuote -= spent; | |
} | |
} | |
if (remainingQuote <= 0) break; | |
// Take from OB at this level (limited by budget) | |
const maxFromThisLevel = Math.min(lvl.qtyBase, remainingQuote / pOB); | |
if (maxFromThisLevel > 0) { | |
const spent = maxFromThisLevel * pOB; | |
fromOB += spent; gotBase += maxFromThisLevel; remainingQuote -= spent; | |
} | |
} | |
// Finish remaining budget on AMM | |
if (remainingQuote > 0) { | |
const dq = ammBuyDeltaQForBudget(qAMM, remainingQuote, x, y, feeUnits, trIn, trOut); | |
const before = ammBuyCost(qAMM, x, y, feeUnits, trIn, trOut); | |
const after = ammBuyCost(qAMM + dq, x, y, feeUnits, trIn, trOut); | |
const spent = after - before; | |
fromAMM += spent; gotBase += dq; qAMM += dq; remainingQuote = 0; | |
} | |
return { | |
side, base, quote, | |
fromAmount: amount, | |
toAmount: gotBase, | |
fromOrderbook: fromOB, | |
fromAMM, | |
blendedPriceQuotePerBase: (fromOB + fromAMM) / Math.max(gotBase, 1e-12), | |
}; | |
} | |
} else { | |
// side === 'sell' | |
const book = await client.request({ | |
command: "book_offers", | |
taker_pays: isXRP(base) ? "XRP" : base, | |
taker_gets: isXRP(quote) ? "XRP" : quote, | |
ledger_index: "current", | |
limit: 200, | |
}); | |
const bids = normalizeBids(book.result.offers); | |
if (mode === "from") { | |
// sell known BASE, receive QUOTE | |
remainingBase = amount; | |
for (const lvl of bids) { | |
if (remainingBase <= 0) break; | |
const pOB = lvl.price; | |
// While AMM pays more than this bid, route to AMM up to equality | |
const pAM = ammSellMarginal(qAMM, x, y, feeUnits, trIn, trOut); | |
if (pAM > pOB) { | |
const fStar = solveSellQForMarginalEqual(pOB, qAMM, x, y, feeUnits, trIn, trOut); | |
const take = Math.min(remainingBase, Math.max(0, fStar - qAMM)); | |
if (take > 0) { | |
const before = ammSellOut(qAMM, x, y, feeUnits, trIn, trOut); | |
const after = ammSellOut(qAMM + take, x, y, feeUnits, trIn, trOut); | |
const got = after - before; | |
fromAMM += got; qAMM += take; remainingBase -= take; | |
} | |
} | |
if (remainingBase <= 0) break; | |
const takeOB = Math.min(remainingBase, lvl.qtyBase); | |
if (takeOB > 0) { | |
const got = takeOB * pOB; | |
fromOB += got; remainingBase -= takeOB; | |
} | |
} | |
// Finish on AMM | |
if (remainingBase > 0) { | |
const before = ammSellOut(qAMM, x, y, feeUnits, trIn, trOut); | |
const after = ammSellOut(qAMM + remainingBase, x, y, feeUnits, trIn, trOut); | |
const got = after - before; | |
fromAMM += got; qAMM += remainingBase; remainingBase = 0; | |
} | |
const total = fromOB + fromAMM; | |
return { | |
side, base, quote, | |
fromAmount: amount, | |
toAmount: total, | |
fromOrderbook: fromOB, | |
fromAMM, | |
blendedPriceQuotePerBase: total / amount, | |
}; | |
} else { | |
// mode === 'to' → want exact QUOTE proceeds | |
remainingQuote = amount; | |
for (const lvl of bids) { | |
if (remainingQuote <= 0) break; | |
const pOB = lvl.price; | |
// While AMM pays more than this bid, take AMM until equal or reach proceeds target | |
const pAM = ammSellMarginal(qAMM, x, y, feeUnits, trIn, trOut); | |
if (pAM > pOB) { | |
const qStar = solveSellQForMarginalEqual(pOB, qAMM, x, y, feeUnits, trIn, trOut); | |
const needToEqual = ammSellOut(qStar, x, y, feeUnits, trIn, trOut) - ammSellOut(qAMM, x, y, feeUnits, trIn, trOut); | |
if (remainingQuote <= needToEqual) { | |
const dq = ammSellDeltaQForProceeds(qAMM, remainingQuote, x, y, feeUnits, trIn, trOut); | |
const before = ammSellOut(qAMM, x, y, feeUnits, trIn, trOut); | |
const after = ammSellOut(qAMM + dq, x, y, feeUnits, trIn, trOut); | |
const got = after - before; | |
fromAMM += got; qAMM += dq; remainingQuote = 0; | |
break; | |
} else { | |
const dq = Math.max(0, qStar - qAMM); | |
const before = ammSellOut(qAMM, x, y, feeUnits, trIn, trOut); | |
const after = ammSellOut(qAMM + dq, x, y, feeUnits, trIn, trOut); | |
const got = after - before; | |
fromAMM += got; qAMM += dq; remainingQuote -= got; | |
} | |
} | |
if (remainingQuote <= 0) break; | |
// Fill OB bid | |
const baseNeeded = Math.min(lvl.qtyBase, remainingQuote / pOB); | |
if (baseNeeded > 0) { | |
const got = baseNeeded * pOB; | |
fromOB += got; remainingQuote -= got; | |
} | |
} | |
// Finish remaining proceeds on AMM | |
if (remainingQuote > 0) { | |
const dq = ammSellDeltaQForProceeds(qAMM, remainingQuote, x, y, feeUnits, trIn, trOut); | |
const before = ammSellOut(qAMM, x, y, feeUnits, trIn, trOut); | |
const after = ammSellOut(qAMM + dq, x, y, feeUnits, trIn, trOut); | |
const got = after - before; | |
fromAMM += got; qAMM += dq; remainingQuote = 0; | |
} | |
const totalBase = (fromOB + fromAMM) / Math.max((fromOB + fromAMM) / Math.max(qAMM, 1e-12), 1e-12); // not crucial; you likely want to track base taken from OB too if needed | |
return { | |
side, base, quote, | |
fromAmount: (await (async () => { | |
// compute total base sold: AMM part = qAMM; OB part = fromOB / avg OB price (track if needed) | |
// For UI you mainly need fromAmount for 'to' mode sell if you show "you need to sell ~X base". | |
// Quick estimate: use bids again or track baseTakenOB in the loop similarly to buy. | |
return qAMM; // minimal; add OB base tally if you want exact | |
})()), | |
toAmount: amount, | |
fromOrderbook: fromOB, | |
fromAMM, | |
blendedPriceQuotePerBase: (fromOB + fromAMM) / Math.max(qAMM, 1e-12), | |
}; | |
} | |
} | |
} | |
// Helper for sell marginal equality | |
function solveSellQForMarginalEqual(pTarget: number, q0: number, x: number, y: number, feeUnits: number, trIn: number, trOut: number) { | |
const f = feeUnits / 100000; | |
const k = trIn * (1 - f); | |
const s = Math.sqrt((y * k * x) / (trOut * pTarget)); | |
const qStar = (s - x) / k; | |
return Math.max(q0, qStar); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment