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
61 changes: 61 additions & 0 deletions src/__tests__/priceOracle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
PriceOracle,
parseOraclePrice,
type OraclePrice,
} from "../priceOracle";

describe("parseOraclePrice", () => {
it("parses a numeric price and timestamp", () => {
const result = parseOraclePrice("XLM", { price: 0.42, timestamp: 1700000000000 });
expect(result).toEqual({ symbol: "XLM", price: 0.42, timestamp: 1700000000000 });
});

it("parses string prices and aliases", () => {
const result = parseOraclePrice("USDC", { value: "1.01", time: 1700000000000 });
expect(result.price).toBe(1.01);
expect(result.timestamp).toBe(1700000000000);
});

it("falls back to the current time when no timestamp is present", () => {
const before = Date.now();
const result = parseOraclePrice("XLM", { amount: 2 });
expect(result.timestamp).toBeGreaterThanOrEqual(before);
});

it("throws on an invalid price", () => {
expect(() => parseOraclePrice("XLM", { price: "not-a-number" })).toThrow(
/Invalid oracle price/,
);
expect(() => parseOraclePrice("XLM", {})).toThrow(/Invalid oracle price/);
});
});

describe("PriceOracle", () => {
it("fetches, parses, and caches a price", async () => {
const oracle = new PriceOracle(async () => ({ price: 3.5, timestamp: 1 }));
const price = await oracle.fetchPrice("XLM");
expect(price).toEqual({ symbol: "XLM", price: 3.5, timestamp: 1 });
expect(oracle.getCachedPrice("XLM")).toEqual(price);
});

it("emits price updates to listeners", async () => {
const oracle = new PriceOracle(async () => ({ price: 7 }));
const received: OraclePrice[] = [];
const unsubscribe = oracle.onPriceUpdate((p) => received.push(p));

await oracle.fetchPrice("XLM");
expect(received).toHaveLength(1);
expect(received[0].price).toBe(7);

unsubscribe();
await oracle.fetchPrice("XLM");
expect(received).toHaveLength(1);
});

it("fetches multiple prices in order", async () => {
const oracle = new PriceOracle(async (symbol) => ({ price: symbol.length }));
const prices = await oracle.fetchPrices(["XLM", "USDC"]);
expect(prices.map((p) => p.symbol)).toEqual(["XLM", "USDC"]);
expect(prices.map((p) => p.price)).toEqual([3, 4]);
});
});
224 changes: 222 additions & 2 deletions src/ammCalculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,232 @@ export function calculatePoolShare(
};
}

// ---------------------------------------------------------------------------
// Price oracle integration helpers
// ---------------------------------------------------------------------------

/**
* A single price observation returned by a price oracle source.
*/
export interface OraclePriceObservation {
/** Asset identifier the price refers to (e.g. "native" or "USDC:GA..."). */
asset: string;
/** Price expressed as a decimal string, in the oracle's quote asset. */
price: string;
/** Unix timestamp (seconds) at which the price was observed. */
timestamp: number;
}

/**
* A price oracle source that can be queried for the latest price of an asset.
* Implementations may wrap an on-chain oracle contract, an HTTP feed, or a
* cached in-memory source.
*/
export interface PriceOracleSource {
/** Fetches the latest observation for the given asset. */
getPrice(asset: string): Promise<OraclePriceObservation>;
}

/**
* A parsed, validated oracle price ready for use in AMM calculations.
*/
export interface ParsedOraclePrice {
asset: string;
/** Price as a decimal string, normalized to a fixed precision. */
price: string;
/** Price scaled to an integer string (price * 10^decimals). */
scaledPrice: string;
/** Number of decimal places used for scaledPrice. */
decimals: number;
timestamp: number;
/** Age of the observation in seconds relative to the provided `now`. */
ageSeconds: number;
/** Whether the observation is older than the staleness threshold. */
stale: boolean;
}

/**
* Options controlling how oracle prices are parsed and validated.
*/
export interface OraclePriceOptions {
/** Decimal places used when scaling the price. Defaults to 7 (Stellar). */
decimals?: number;
/** Maximum acceptable age in seconds before a price is flagged stale. */
maxAgeSeconds?: number;
/** Reference time (unix seconds) used to compute age. Defaults to now. */
now?: number;
}

const DEFAULT_ORACLE_DECIMALS = 7;
const DEFAULT_ORACLE_MAX_AGE_SECONDS = 300;

/**
* Fetches the latest price for `asset` from the given oracle source and parses
* it into a validated {@link ParsedOraclePrice}.
*
* @param source - The oracle source to query.
* @param asset - The asset identifier to fetch a price for.
* @param options - Parsing/validation options.
* @throws Error when the source returns a malformed or non-positive price.
*/
export async function fetchOraclePrice(
source: PriceOracleSource,
asset: string,
options: OraclePriceOptions = {}
): Promise<ParsedOraclePrice> {
const observation = await source.getPrice(asset);
return parseOraclePrice(observation, options);
}

/**
* Parses and validates a raw {@link OraclePriceObservation} into a
* {@link ParsedOraclePrice}, scaling the price to an integer string and
* computing staleness relative to `options.now`.
*
* @param observation - The raw observation to parse.
* @param options - Parsing/validation options.
* @throws Error when the price is missing, malformed, or non-positive.
*/
export function parseOraclePrice(
observation: OraclePriceObservation,
options: OraclePriceOptions = {}
): ParsedOraclePrice {
const decimals = options.decimals ?? DEFAULT_ORACLE_DECIMALS;
const maxAgeSeconds =
options.maxAgeSeconds ?? DEFAULT_ORACLE_MAX_AGE_SECONDS;
const now = options.now ?? Math.floor(Date.now() / 1000);

if (!observation || typeof observation.price !== "string") {
throw new Error("Oracle price observation is missing a price");
}

const price = observation.price.trim();
if (!/^\d+(\.\d+)?$/.test(price)) {
throw new Error(`Malformed oracle price: ${observation.price}`);
}

const scaledPrice = scaleDecimal(price, decimals);
if (BigInt(scaledPrice) <= 0n) {
throw new Error(`Oracle price must be positive: ${observation.price}`);
}

const ageSeconds = Math.max(0, now - observation.timestamp);

return {
asset: observation.asset,
price,
scaledPrice,
decimals,
timestamp: observation.timestamp,
ageSeconds,
stale: ageSeconds > maxAgeSeconds,
};
}

/**
* Computes the cross price between two assets using their oracle prices.
*
* Given `base` priced in quote units and `counter` priced in the same quote
* units, returns how many `counter` units one `base` unit is worth, as a
* decimal string with `decimals` places of precision.
*
* @param base - Parsed price for the base asset.
* @param counter - Parsed price for the counter asset.
* @param decimals - Output precision. Defaults to the base price decimals.
* @throws Error when the counter price is zero.
*/
export function computeCrossPrice(
base: ParsedOraclePrice,
counter: ParsedOraclePrice,
decimals: number = base.decimals
): string {
const counterScaled = BigInt(counter.scaledPrice);
if (counterScaled === 0n) {
throw new Error("Cannot compute cross price with a zero counter price");
}

const baseScaled = BigInt(base.scaledPrice);
const SCALE = 10n ** BigInt(decimals);
const result = (baseScaled * SCALE) / counterScaled;

return formatScaled(result, decimals);
}

/**
* Subscribes to oracle price updates for a set of assets, invoking `onUpdate`
* whenever a new observation is produced. Returns an unsubscribe function.
*
* The source is polled at `intervalMs`; each poll fetches every asset and
* emits only observations whose price or timestamp changed since the previous
* poll. Errors from the source are forwarded to `onError` (if provided) and
* do not stop the subscription.
*
* @param source - The oracle source to poll.
* @param assets - Asset identifiers to watch.
* @param onUpdate - Callback invoked with each changed parsed price.
* @param options - Polling interval, parse options, and error handler.
* @returns A function that stops the subscription when called.
*/
export function subscribeToOraclePrices(
source: PriceOracleSource,
assets: string[],
onUpdate: (price: ParsedOraclePrice) => void,
options: OraclePriceOptions & {
intervalMs?: number;
onError?: (error: unknown) => void;
} = {}
): () => void {
const intervalMs = options.intervalMs ?? 15_000;
const lastSeen = new Map<string, string>();
let stopped = false;

const poll = async (): Promise<void> => {
for (const asset of assets) {
if (stopped) return;
try {
const parsed = await fetchOraclePrice(source, asset, options);
const fingerprint = `${parsed.price}@${parsed.timestamp}`;
if (lastSeen.get(asset) !== fingerprint) {
lastSeen.set(asset, fingerprint);
onUpdate(parsed);
}
} catch (error) {
options.onError?.(error);
}
}
};

void poll();
const timer = setInterval(() => {
void poll();
}, intervalMs);

return () => {
stopped = true;
clearInterval(timer);
};
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

function scaleDecimal(value: string, decimals: number): string {
const dot = value.indexOf(".");
const intPart = dot === -1 ? value : value.slice(0, dot);
const fracPart = dot === -1 ? "" : value.slice(dot + 1);
const paddedFrac = fracPart.padEnd(decimals, "0").slice(0, decimals);
return `${intPart}${paddedFrac}`.replace(/^0+(?=\d)/, "") || "0";
}

function formatScaled(value: bigint, decimals: number): string {
const SCALE = 10n ** BigInt(decimals);
const intPart = value / SCALE;
const fracPart = value % SCALE;
if (decimals === 0) return intPart.toString();
return `${intPart}.${fracPart.toString().padStart(decimals, "0")}`;
}

function computeSpotPrice(reserveIn: bigint, reserveOut: bigint): string {
// spotPrice = reserveOut / reserveIn as a decimal string
if (reserveIn === 0n) return "0";
Expand Down Expand Up @@ -246,8 +468,6 @@ function computeOwnershipPercent(owned: bigint, total: bigint): string {

function formatRatio(numerator: bigint, denominator: bigint): string {
if (denominator === 0n) return "0";
// Use BigInt-safe decimal division with up to 12 decimal places.
// Multiply numerator by 10^12 before division, then insert decimal point.
const SCALE = 10n ** 12n;
const scaled = (numerator * SCALE) / denominator;
const intPart = scaled / SCALE;
Expand Down
Loading