diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 663914a0..c6b11800 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -22,7 +22,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@/ts-sdk": ["../ts-sdk/src/index.ts"], + "@/ts-sdk/*": ["../ts-sdk/src/*"] } }, "include": [ diff --git a/package.json b/package.json index 980d151c..66587932 100644 --- a/package.json +++ b/package.json @@ -16,15 +16,15 @@ "check:bench": "pnpm run check:bench:manifest && pnpm run check:bench:phoenix", "check:bench:manifest": "cd cu-bench/manifest && cargo check --tests", "check:bench:phoenix": "cd cu-bench/phoenix && cargo check --tests", - "lint": "pnpm run lint:biome && pnpm run lint:rust", - "lint:fix": "pnpm run lint:biome:fix && pnpm run lint:rust:fix", - "lint:biome": "biome check", - "lint:biome:fix": "biome check --write", + "lint": "pnpm run lint:ts && pnpm run lint:rust", + "lint:fix": "pnpm run lint:ts:fix && pnpm run lint:rust:fix", + "lint:ts": "biome check", + "lint:ts:fix": "biome check --write", "lint:rust": "cargo +nightly clippy --workspace --all-targets -- -D warnings", "lint:rust:fix": "cargo +nightly clippy --fix --workspace --all-targets --allow-dirty --allow-staged -- -D warnings", "lint:generated": "biome check --write codama-idl-gen ts-sdk/src/generated", - "format": "pnpm run format:biome && pnpm run format:rust", - "format:biome": "biome format --write", + "format": "pnpm run format:ts && pnpm run format:rust", + "format:ts": "biome format --write", "format:rust": "cargo +nightly fmt --all", "test": "pnpm run test:ts && pnpm run test:rust", "test:ts": "turbo run test", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c58b50b0..4e5059d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,7 +11,8 @@ catalog: "@jest/globals": ^29.7.0 "@solana/kit": ^6.1.0 "@types/node": ^24 + "decimal.js": ^10.6.0 "jest": ^29.7.0 "ts-jest": ^29.4.6 - "typescript": ^5 + "typescript": ^5.9 diff --git a/ts-sdk/package.json b/ts-sdk/package.json index 272b90fc..77522bf2 100644 --- a/ts-sdk/package.json +++ b/ts-sdk/package.json @@ -44,9 +44,10 @@ }, "typings": "dist/common/index.d.ts", "dependencies": { - "@solana/program-client-core": "^6.5.0", "@solana/kit": "catalog:", - "@solana/rpc-transport-http": "^6.5.0" + "@solana/program-client-core": "^6.5.0", + "@solana/rpc-transport-http": "^6.5.0", + "decimal.js": "catalog:" }, "devDependencies": { "@jest/globals": "catalog:", diff --git a/ts-sdk/src/const.ts b/ts-sdk/src/const.ts index 97a37790..fdd6bafb 100644 --- a/ts-sdk/src/const.ts +++ b/ts-sdk/src/const.ts @@ -2,7 +2,7 @@ import { getMarketHeaderDecoder, getMarketSeatDecoder, getSectorDecoder, -} from "@/generated"; +} from "@/ts-sdk/generated"; export const LOCALNET_URL = "http://localhost:8899"; export const NIL = 0xffffffff; diff --git a/ts-sdk/src/dropset-interface/market-view-all.ts b/ts-sdk/src/dropset-interface/market-view-all.ts index 95cb66d7..86fcaf44 100644 --- a/ts-sdk/src/dropset-interface/market-view-all.ts +++ b/ts-sdk/src/dropset-interface/market-view-all.ts @@ -3,20 +3,20 @@ import type { FixedSizeDecoder, ReadonlyUint8Array, } from "@solana/kit"; -import { NIL, SECTOR_SIZE } from "@/const"; +import { NIL, SECTOR_SIZE } from "@/ts-sdk/const"; import type { MarketAccount, MarketHeader, MarketSeat, Order, Sector, -} from "@/generated"; +} from "@/ts-sdk/generated"; import { getMarketSeatDecoder, getOrderDecoder, getSectorDecoder, -} from "@/generated"; -import type { Flatten, SectorIndex } from "@/types"; +} from "@/ts-sdk/generated"; +import type { Flatten, SectorIndex } from "@/ts-sdk/types"; export type MarketViewAll = { header: MarketHeader; diff --git a/ts-sdk/src/index.ts b/ts-sdk/src/index.ts index bacf02b7..c203672e 100644 --- a/ts-sdk/src/index.ts +++ b/ts-sdk/src/index.ts @@ -1,4 +1,6 @@ export * from "./dropset-interface"; export * from "./generated"; +export * from "./price"; +export * from "./rust-types"; export * from "./types"; export * from "./utils"; diff --git a/ts-sdk/src/price/client-helpers.ts b/ts-sdk/src/price/client-helpers.ts new file mode 100644 index 00000000..2cce7b58 --- /dev/null +++ b/ts-sdk/src/price/client-helpers.ts @@ -0,0 +1,105 @@ +import { Decimal } from "decimal.js"; +import { + ensureU8, + ensureU64, + type U8, + type U32, + type U64, +} from "../rust-types"; +import { decodedPriceToDecimal, decodePrice } from "./decoded-price"; +import { PriceError } from "./error"; +import { + BIAS, + normalizePriceMantissa, + UNBIASED_MAX, + UNBIASED_MIN, +} from "./lib"; + +/** Multiply `value` by `10^pow`. Port of `decimal_pow10` in `price/src/client_helpers.rs`. */ +export function decimalPow10(value: Decimal, pow: number): Decimal { + if (pow === 0) return value; + return value.times(new Decimal(10).pow(pow)); +} + +/** Port of `try_to_biased_exponent` in `price/src/client_helpers.rs`. */ +export function toBiasedExponent(unbiased: number): U8 { + if (unbiased < UNBIASED_MIN || unbiased > UNBIASED_MAX) { + throw new Error(PriceError.InvalidBiasedExponent); + } + return ensureU8(unbiased + BIAS); +} + +/** Port of `atoms_to_ui_amount` in `price/src/client_helpers.rs`. */ +export function atomsToUiAmount( + atomsAmount: bigint, + mintDecimals: number | bigint, +): Decimal { + const dec = ensureU8(mintDecimals); + return decimalPow10(new Decimal(atomsAmount.toString()), -dec); +} + +/** + * Convert a UI price (human-readable quote/base) to an atoms-denominated price, + * accounting for differing base/quote decimals. + * + * `atomsPrice = uiPrice * 10^(quoteDecimals - baseDecimals)` + * + * Port of `ui_price_to_atoms_price` in `price/src/client_helpers.rs`. + */ +export function uiPriceToAtomsPrice( + uiPrice: Decimal, + baseDecimals: number | bigint, + quoteDecimals: number | bigint, +): Decimal { + const base = ensureU8(baseDecimals); + const quote = ensureU8(quoteDecimals); + return decimalPow10(uiPrice, quote - base); +} + +/** Port of `try_encoded_u32_to_decoded_decimal` in `price/src/client_helpers.rs`. */ +export function encodedU32ToDecimal(encodedU32: number | bigint): Decimal { + return decodedPriceToDecimal(decodePrice(encodedU32)); +} + +/** Port of `get_sig_figs` in `price/src/client_helpers.rs`. */ +function getSigFigs(value: bigint): { scalar: bigint; pow: number } { + if (value === 0n) throw new Error(PriceError.AmountCannotBeZero); + let x = value; + let pow = 0; + while (x % 10n === 0n) { + x /= 10n; + pow += 1; + } + return { scalar: x, pow }; +} + +/** + * Convert a decimal price and base-atoms order size into `OrderInfoArgs`-equivalent values. + * + * Port of `to_order_info_args` in `price/src/client_helpers.rs`. + */ +export function toOrderInfoArgs( + price: Decimal, + orderSizeBaseAtoms: bigint, +): { + priceMantissa: U32; + baseScalar: U64; + baseExponentBiased: U8; + quoteExponentBiased: U8; +} { + const { mantissa, scale: priceExponent } = normalizePriceMantissa(price); + + if (orderSizeBaseAtoms === 0n) throw new Error(PriceError.AmountCannotBeZero); + ensureU64(orderSizeBaseAtoms); + + const { scalar: baseScalar, pow: baseExponentUnbiased } = + getSigFigs(orderSizeBaseAtoms); + const quoteExponentUnbiased = priceExponent + baseExponentUnbiased; + + return { + priceMantissa: mantissa.value, + baseScalar: ensureU64(baseScalar), + baseExponentBiased: toBiasedExponent(baseExponentUnbiased), + quoteExponentBiased: toBiasedExponent(quoteExponentUnbiased), + }; +} diff --git a/ts-sdk/src/price/decoded-price.ts b/ts-sdk/src/price/decoded-price.ts new file mode 100644 index 00000000..3fed1a55 --- /dev/null +++ b/ts-sdk/src/price/decoded-price.ts @@ -0,0 +1,50 @@ +import { Decimal } from "decimal.js"; +import { ensureU8, ensureU32, type U8 } from "../rust-types"; +import { decimalPow10 } from "./client-helpers"; +import { ENCODED_PRICE_INFINITY, ENCODED_PRICE_ZERO } from "./encoded-price"; +import { PriceError } from "./error"; +import { BIAS, PRICE_MANTISSA_BITS, PRICE_MANTISSA_MASK } from "./lib"; +import { + type ValidatedPriceMantissa, + validatePriceMantissa, +} from "./validated-mantissa"; + +/** + * An enum representing a decoded `EncodedPrice`. + * + * Port of `DecodedPrice` in `price/src/decoded_price.rs`. + */ +export type DecodedPrice = + | { kind: "zero" } + | { kind: "infinity" } + | { + kind: "value"; + biasedExponent: U8; + mantissa: ValidatedPriceMantissa; + }; + +/** Port of `DecodedPrice::try_from(EncodedPrice)` in `price/src/decoded_price.rs`. */ +export function decodePrice(encoded: number | bigint): DecodedPrice { + const v = ensureU32(encoded); + if (v === ENCODED_PRICE_ZERO) return { kind: "zero" }; + if (v === ENCODED_PRICE_INFINITY) return { kind: "infinity" }; + + const biasedExponent = ensureU8(v >>> PRICE_MANTISSA_BITS); + const mantissa = validatePriceMantissa(v & PRICE_MANTISSA_MASK); + return { kind: "value", biasedExponent, mantissa }; +} + +/** Port of `Decimal::try_from(DecodedPrice)` in `price/src/decoded_price.rs`. */ +export function decodedPriceToDecimal(decoded: DecodedPrice): Decimal { + switch (decoded.kind) { + case "zero": + return new Decimal(0); + case "infinity": + throw new Error(PriceError.InfinityIsNotADecimal); + case "value": + return decimalPow10( + new Decimal(decoded.mantissa.value), + decoded.biasedExponent - BIAS, + ); + } +} diff --git a/ts-sdk/src/price/encoded-price.ts b/ts-sdk/src/price/encoded-price.ts new file mode 100644 index 00000000..930d5233 --- /dev/null +++ b/ts-sdk/src/price/encoded-price.ts @@ -0,0 +1,37 @@ +import { ensureU8, type U32, U32_MAX } from "../rust-types"; +import { PriceError } from "./error"; +import { PRICE_EXPONENT_MAX, PRICE_MANTISSA_BITS } from "./lib"; +import type { ValidatedPriceMantissa } from "./validated-mantissa"; + +/** + * An encoded price packed into a u32: `[exponent_bits | mantissa_bits]`. + * + * Port of `EncodedPrice` in `price/src/encoded_price.rs`. + */ +export type EncodedPrice = U32; + +const ENCODED_PRICE_INFINITY = U32_MAX as EncodedPrice; +const ENCODED_PRICE_ZERO = 0 as EncodedPrice; + +export { ENCODED_PRICE_INFINITY, ENCODED_PRICE_ZERO }; + +/** Port of `EncodedPrice::new` in `price/src/encoded_price.rs`. */ +export function encodePrice( + mantissa: ValidatedPriceMantissa, + biasedExponent: number | bigint, +): EncodedPrice { + const exp = ensureU8(biasedExponent); + if (exp > PRICE_EXPONENT_MAX) { + throw new Error(PriceError.InvalidBiasedExponent); + } + return (((exp << PRICE_MANTISSA_BITS) | mantissa.value) >>> + 0) as EncodedPrice; +} + +export function isEncodedPriceInfinity(encoded: EncodedPrice): boolean { + return encoded === ENCODED_PRICE_INFINITY; +} + +export function isEncodedPriceZero(encoded: EncodedPrice): boolean { + return encoded === ENCODED_PRICE_ZERO; +} diff --git a/ts-sdk/src/price/error.ts b/ts-sdk/src/price/error.ts new file mode 100644 index 00000000..d45e53a4 --- /dev/null +++ b/ts-sdk/src/price/error.ts @@ -0,0 +1,9 @@ +/** Port of `OrderInfoError` in `price/src/error.rs`. */ +export enum PriceError { + ExponentUnderflow = "ExponentUnderflow", + ArithmeticOverflow = "ArithmeticOverflow", + InvalidPriceMantissa = "InvalidPriceMantissa", + InvalidBiasedExponent = "InvalidBiasedExponent", + InfinityIsNotADecimal = "InfinityIsNotADecimal", + AmountCannotBeZero = "AmountCannotBeZero", +} diff --git a/ts-sdk/src/price/index.ts b/ts-sdk/src/price/index.ts new file mode 100644 index 00000000..ee9f3730 --- /dev/null +++ b/ts-sdk/src/price/index.ts @@ -0,0 +1,7 @@ +export * from "./client-helpers"; +export * from "./decoded-price"; +export * from "./encoded-price"; +export * from "./error"; +export * from "./lib"; +export * from "./utils"; +export * from "./validated-mantissa"; diff --git a/ts-sdk/src/price/lib.ts b/ts-sdk/src/price/lib.ts new file mode 100644 index 00000000..77b9a42d --- /dev/null +++ b/ts-sdk/src/price/lib.ts @@ -0,0 +1,102 @@ +import { + ensureU8, + ensureU64, + type U8, + type U32, + type U64, +} from "../rust-types"; +import { type EncodedPrice, encodePrice } from "./encoded-price"; +import { PriceError } from "./error"; +import { + normalizePriceMantissa, + validatePriceMantissa, +} from "./validated-mantissa"; + +/** Port of `MANTISSA_DIGITS_LOWER_BOUND` in `price/src/lib.rs`. */ +export const MANTISSA_DIGITS_LOWER_BOUND = 10_000_000; +/** Port of `MANTISSA_DIGITS_UPPER_BOUND` in `price/src/lib.rs`. */ +export const MANTISSA_DIGITS_UPPER_BOUND = 99_999_999; + +export const PRICE_MANTISSA_BITS = 27; +export const PRICE_MANTISSA_MASK = 0xffff_ffff >>> (32 - PRICE_MANTISSA_BITS); +export const PRICE_EXPONENT_MAX = (1 << (32 - PRICE_MANTISSA_BITS)) - 1; + +export const BIAS = 16; +export const UNBIASED_MIN = -BIAS; +export const UNBIASED_MAX = (1 << (32 - PRICE_MANTISSA_BITS)) - 1 - BIAS; + +/** Port of the `pow10_u64!` macro in `price/src/macros.rs`. */ +export function pow10Bigint( + value: bigint, + biasedExponent: number | bigint, +): bigint { + const exp = ensureU8(biasedExponent); + if (exp === BIAS) return value; + + if (exp > PRICE_EXPONENT_MAX) { + throw new Error(PriceError.InvalidBiasedExponent); + } + + const unbiased = exp - BIAS; + if (unbiased < 0) { + return value / 10n ** BigInt(-unbiased); + } + return value * 10n ** BigInt(unbiased); +} + +/** + * Compute full order info (encoded price, base atoms, quote atoms) from order args. + * + * Port of `to_order_info` in `price/src/lib.rs`. + */ +export function toOrderInfo(args: { + priceMantissa: number | bigint; + baseScalar: bigint; + baseExponentBiased: number | bigint; + quoteExponentBiased: number | bigint; +}): { + encodedPrice: EncodedPrice; + baseAtoms: U64; + quoteAtoms: U64; +} { + const mantissa = validatePriceMantissa(args.priceMantissa); + const baseExp = ensureU8(args.baseExponentBiased); + const quoteExp = ensureU8(args.quoteExponentBiased); + + const baseAtoms = ensureU64(pow10Bigint(args.baseScalar, baseExp)); + const quoteAtoms = ensureU64( + pow10Bigint(BigInt(mantissa.value) * args.baseScalar, quoteExp), + ); + + // Re-bias: price_exponent = quote_exponent_biased + BIAS - base_exponent_biased + const rebiased = quoteExp + BIAS - baseExp; + if (rebiased < 0) throw new Error(PriceError.ExponentUnderflow); + + return { + encodedPrice: encodePrice(mantissa, rebiased), + baseAtoms, + quoteAtoms, + }; +} + +/** + * Creates order args such that the output `EncodedPrice` from `toOrderInfo` + * equals the input `priceMantissa` exactly. + * + * Port of `OrderInfoArgs::order_at_price` in `price/src/lib.rs`. + */ +export function orderAtPrice(priceMantissa: number | bigint): { + priceMantissa: U32; + baseScalar: bigint; + baseExponentBiased: U8; + quoteExponentBiased: U8; +} { + return { + priceMantissa: validatePriceMantissa(priceMantissa).value, + baseScalar: 1n, + baseExponentBiased: ensureU8(UNBIASED_MAX + BIAS), + quoteExponentBiased: ensureU8(BIAS - 1), + }; +} + +export { validatePriceMantissa, normalizePriceMantissa }; diff --git a/ts-sdk/src/price/utils.ts b/ts-sdk/src/price/utils.ts new file mode 100644 index 00000000..5fdedf5b --- /dev/null +++ b/ts-sdk/src/price/utils.ts @@ -0,0 +1,66 @@ +import { Decimal } from "decimal.js"; + +/** + * Returns a Decimal constructor with enough precision to exactly represent + * the result of an operation on `a` and `b` (no silent rounding). + */ +function preciseDecimal(a: bigint, b: Decimal): typeof Decimal { + const need = a.toString().length + b.precision(true) + 1; + return need <= Decimal.precision + ? Decimal + : Decimal.clone({ precision: need }); +} + +/** + * Convert a base amount to its equivalent quote amount at the given price. + * + * `quote = base * price` + * + * The output is in the same denomination as the input: pass atoms and an + * atoms-denominated price to get quote atoms; pass UI amounts and a UI price + * to get a UI quote amount. + */ +export function quoteFromBase(baseAmount: bigint, price: Decimal): bigint; +export function quoteFromBase(baseAmount: Decimal, price: Decimal): Decimal; +export function quoteFromBase( + baseAmount: bigint | Decimal, + price: Decimal, +): bigint | Decimal { + if (typeof baseAmount === "bigint") { + const D = preciseDecimal(baseAmount, price); + return BigInt( + new D(baseAmount.toString()) + .times(price) + .toDP(0, Decimal.ROUND_DOWN) + .toFixed(), + ); + } + return baseAmount.times(price); +} + +/** + * Convert a quote amount to its equivalent base amount at the given price. + * + * `base = quote / price` + * + * The output is in the same denomination as the input: pass atoms and an + * atoms-denominated price to get base atoms; pass UI amounts and a UI price + * to get a UI base amount. + */ +export function baseFromQuote(quoteAmount: bigint, price: Decimal): bigint; +export function baseFromQuote(quoteAmount: Decimal, price: Decimal): Decimal; +export function baseFromQuote( + quoteAmount: bigint | Decimal, + price: Decimal, +): bigint | Decimal { + if (typeof quoteAmount === "bigint") { + const D = preciseDecimal(quoteAmount, price); + return BigInt( + new D(quoteAmount.toString()) + .div(price) + .toDP(0, Decimal.ROUND_DOWN) + .toFixed(), + ); + } + return quoteAmount.div(price); +} diff --git a/ts-sdk/src/price/validated-mantissa.ts b/ts-sdk/src/price/validated-mantissa.ts new file mode 100644 index 00000000..51eece19 --- /dev/null +++ b/ts-sdk/src/price/validated-mantissa.ts @@ -0,0 +1,70 @@ +import { Decimal } from "decimal.js"; +import { ensureU32, type U32 } from "../rust-types"; +import { PriceError } from "./error"; +import { + MANTISSA_DIGITS_LOWER_BOUND, + MANTISSA_DIGITS_UPPER_BOUND, +} from "./lib"; + +/** + * A price mantissa validated to be within `[MANTISSA_DIGITS_LOWER_BOUND, MANTISSA_DIGITS_UPPER_BOUND]`. + * + * Port of `ValidatedPriceMantissa` in `price/src/validated_mantissa.rs`. + */ +export type ValidatedPriceMantissa = { + readonly __brand: "ValidatedPriceMantissa"; + readonly value: U32; +}; + +/** Port of `ValidatedPriceMantissa::try_from` in `price/src/validated_mantissa.rs`. */ +export function validatePriceMantissa( + mantissa: number | bigint, +): ValidatedPriceMantissa { + const v = ensureU32(mantissa); + if (v < MANTISSA_DIGITS_LOWER_BOUND || v > MANTISSA_DIGITS_UPPER_BOUND) { + throw new Error(PriceError.InvalidPriceMantissa); + } + return { + __brand: "ValidatedPriceMantissa", + value: v, + } as ValidatedPriceMantissa; +} + +/** + * Normalize a decimal price into a validated mantissa and scale, where + * `price = mantissa * 10^scale`. + * + * Port of `ValidatedPriceMantissa::try_into_with_scale` in `price/src/validated_mantissa.rs`. + */ +export function normalizePriceMantissa(price: Decimal): { + mantissa: ValidatedPriceMantissa; + scale: number; +} { + if (price.lte(0) || !price.isFinite()) { + throw new Error(PriceError.InvalidPriceMantissa); + } + + const MAX_ITERS = 100; + let res = price; + let pow = 0; + + const lower = new Decimal(MANTISSA_DIGITS_LOWER_BOUND); + const upperPlusOne = new Decimal(MANTISSA_DIGITS_UPPER_BOUND + 1); + + while (res.lt(lower)) { + res = res.times(10); + pow -= 1; + if (pow < -MAX_ITERS) throw new Error(PriceError.InvalidPriceMantissa); + } + + while (res.gte(upperPlusOne)) { + res = res.div(10); + pow += 1; + if (pow > MAX_ITERS) throw new Error(PriceError.InvalidPriceMantissa); + } + + return { + mantissa: validatePriceMantissa(res.toDP(0, Decimal.ROUND_DOWN).toNumber()), + scale: pow, + }; +} diff --git a/ts-sdk/src/rust-types/error.ts b/ts-sdk/src/rust-types/error.ts new file mode 100644 index 00000000..3ca13376 --- /dev/null +++ b/ts-sdk/src/rust-types/error.ts @@ -0,0 +1,6 @@ +export enum RustTypeError { + InvalidU8 = "InvalidU8", + InvalidU16 = "InvalidU16", + InvalidU32 = "InvalidU32", + InvalidU64 = "InvalidU64", +} diff --git a/ts-sdk/src/rust-types/index.ts b/ts-sdk/src/rust-types/index.ts new file mode 100644 index 00000000..0e97826b --- /dev/null +++ b/ts-sdk/src/rust-types/index.ts @@ -0,0 +1,5 @@ +export * from "./error"; +export * from "./u8"; +export * from "./u16"; +export * from "./u32"; +export * from "./u64"; diff --git a/ts-sdk/src/rust-types/u16.ts b/ts-sdk/src/rust-types/u16.ts new file mode 100644 index 00000000..b59d1547 --- /dev/null +++ b/ts-sdk/src/rust-types/u16.ts @@ -0,0 +1,17 @@ +import { RustTypeError } from "./error"; + +const U16_MAX = 0xffff; + +export { U16_MAX }; + +/** A `number` validated to be a 16-bit unsigned integer. */ +export type U16 = number & { readonly __brand: "U16" }; + +/** Validates that a value is a u16 and returns it branded. */ +export function ensureU16(n: number | bigint): U16 { + if (typeof n === "number" && !Number.isSafeInteger(n)) + throw new Error(RustTypeError.InvalidU16); + const v = BigInt(n); + if (v < 0n || v > BigInt(U16_MAX)) throw new Error(RustTypeError.InvalidU16); + return Number(v) as U16; +} diff --git a/ts-sdk/src/rust-types/u32.ts b/ts-sdk/src/rust-types/u32.ts new file mode 100644 index 00000000..1129bb28 --- /dev/null +++ b/ts-sdk/src/rust-types/u32.ts @@ -0,0 +1,17 @@ +import { RustTypeError } from "./error"; + +const U32_MAX = 0xffff_ffff; + +export { U32_MAX }; + +/** A `number` validated to be a 32-bit unsigned integer. */ +export type U32 = number & { readonly __brand: "U32" }; + +/** Validates that a value is a u32 and returns it branded. */ +export function ensureU32(n: number | bigint): U32 { + if (typeof n === "number" && !Number.isSafeInteger(n)) + throw new Error(RustTypeError.InvalidU32); + const v = BigInt(n); + if (v < 0n || v > BigInt(U32_MAX)) throw new Error(RustTypeError.InvalidU32); + return Number(v) as U32; +} diff --git a/ts-sdk/src/rust-types/u64.ts b/ts-sdk/src/rust-types/u64.ts new file mode 100644 index 00000000..96af1d66 --- /dev/null +++ b/ts-sdk/src/rust-types/u64.ts @@ -0,0 +1,17 @@ +import { RustTypeError } from "./error"; + +const U64_MAX = 0xffff_ffff_ffff_ffffn; + +export { U64_MAX }; + +/** A `bigint` validated to be a 64-bit unsigned integer. */ +export type U64 = bigint & { readonly __brand: "U64" }; + +/** Validates that a value is a u64 and returns it branded. */ +export function ensureU64(n: number | bigint): U64 { + if (typeof n === "number" && !Number.isSafeInteger(n)) + throw new Error(RustTypeError.InvalidU64); + const v = BigInt(n); + if (v < 0n || v > U64_MAX) throw new Error(RustTypeError.InvalidU64); + return v as U64; +} diff --git a/ts-sdk/src/rust-types/u8.ts b/ts-sdk/src/rust-types/u8.ts new file mode 100644 index 00000000..754b363e --- /dev/null +++ b/ts-sdk/src/rust-types/u8.ts @@ -0,0 +1,17 @@ +import { RustTypeError } from "./error"; + +const U8_MAX = 0xff; + +export { U8_MAX }; + +/** A `number` validated to be an 8-bit unsigned integer. */ +export type U8 = number & { readonly __brand: "U8" }; + +/** Validates that a value is a u8 and returns it branded. */ +export function ensureU8(n: number | bigint): U8 { + if (typeof n === "number" && !Number.isSafeInteger(n)) + throw new Error(RustTypeError.InvalidU8); + const v = BigInt(n); + if (v < 0n || v > BigInt(U8_MAX)) throw new Error(RustTypeError.InvalidU8); + return Number(v) as U8; +} diff --git a/ts-sdk/src/tests/e2e/dropset-accounts.test.ts b/ts-sdk/src/tests/e2e/dropset-accounts.test.ts index 3045a26f..bd4f3661 100644 --- a/ts-sdk/src/tests/e2e/dropset-accounts.test.ts +++ b/ts-sdk/src/tests/e2e/dropset-accounts.test.ts @@ -1,21 +1,35 @@ import { describe, expect, it } from "@jest/globals"; -import { toMarketViewAll } from "@/dropset-interface"; -import { deriveMarketAddress, getDropsetMarkets, getRpcClient } from "@/utils"; +import { + deriveMarketAddress, + fetchDropsetMarketAccounts, + fetchDropsetMarketViews, + getRpcClient, +} from "@/ts-sdk/utils"; describe("Dropset market accounts", () => { it("should decode all dropset market accounts", async () => { const rpcClient = getRpcClient(); - const markets = await getDropsetMarkets(rpcClient); - const res = markets.map( - ([address, market]) => [address, toMarketViewAll(market)] as const, - ); + const markets = await fetchDropsetMarketAccounts(rpcClient); - for (const [address, view] of res) { + for (const market of markets) { const [derivedMarketAddress, _] = await deriveMarketAddress( - view.header.baseMint, - view.header.quoteMint, + market.header.baseMint, + market.header.quoteMint, ); - expect(derivedMarketAddress).toBe(address); + expect(derivedMarketAddress).toBe(market.address); + } + }); + + it("should decode all dropset market accounts into market views", async () => { + const rpcClient = getRpcClient(); + const markets = await fetchDropsetMarketViews(rpcClient); + + for (const market of markets) { + const [derivedMarketAddress, _] = await deriveMarketAddress( + market.header.baseMint, + market.header.quoteMint, + ); + expect(derivedMarketAddress).toBe(market.address); } }); }); diff --git a/ts-sdk/src/tests/unit/derive-market-account.test.ts b/ts-sdk/src/tests/unit/derive-market-account.test.ts index 0067b802..bcdc4334 100644 --- a/ts-sdk/src/tests/unit/derive-market-account.test.ts +++ b/ts-sdk/src/tests/unit/derive-market-account.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@jest/globals"; import type { Address } from "@solana/kit"; -import { DROPSET_PROGRAM_ADDRESS } from "@/generated"; -import { deriveMarketAddress } from "@/utils"; +import { DROPSET_PROGRAM_ADDRESS } from "@/ts-sdk/generated"; +import { deriveMarketAddress } from "@/ts-sdk/utils"; describe("Dropset market account derivation", () => { it("should derive a dropset market address correctly", async () => { diff --git a/ts-sdk/src/tests/unit/deserialize-market-account.test.ts b/ts-sdk/src/tests/unit/deserialize-market-account.test.ts index 6a0f2112..34bad254 100644 --- a/ts-sdk/src/tests/unit/deserialize-market-account.test.ts +++ b/ts-sdk/src/tests/unit/deserialize-market-account.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@jest/globals"; -import { NIL } from "@/const"; -import { toMarketViewAll } from "@/dropset-interface/market-view-all"; -import { getMarketAccountDecoder } from "@/generated/accounts"; +import { NIL } from "@/ts-sdk/const"; +import { toMarketViewAll } from "@/ts-sdk/dropset-interface/market-view-all"; +import { getMarketAccountDecoder } from "@/ts-sdk/generated/accounts"; import fixtureBytes from "../fixtures/market-account.json"; describe("Dropset market account deserialization", () => { diff --git a/ts-sdk/src/tests/unit/liquidity.test.ts b/ts-sdk/src/tests/unit/liquidity.test.ts new file mode 100644 index 00000000..e6756e69 --- /dev/null +++ b/ts-sdk/src/tests/unit/liquidity.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "@jest/globals"; +import type { Address } from "@solana/kit"; +import type { OrderView } from "@/ts-sdk/dropset-interface"; +import { + encodePrice, + toBiasedExponent, + validatePriceMantissa, +} from "@/ts-sdk/price"; +import type { DropsetMarketView } from "@/ts-sdk/types"; +import { marketLiquidity, totalLiquidity } from "@/ts-sdk/utils/liquidity"; + +// Encode a price of exactly 2.0: +// mantissa = 20_000_000, unbiased exponent = -7 +// price = 20_000_000 * 10^-7 = 2.0 +const PRICE_2 = encodePrice( + validatePriceMantissa(20_000_000), + toBiasedExponent(-7), +); + +// Encode a price of exactly 0.5: +// mantissa = 50_000_000, unbiased exponent = -8 +// price = 50_000_000 * 10^-8 = 0.5 +const PRICE_0_5 = encodePrice( + validatePriceMantissa(50_000_000), + toBiasedExponent(-8), +); + +function makeOrder( + overrides: Partial & Pick, +): OrderView { + return { + prevIndex: 0, + index: 0, + nextIndex: 0, + userSeatIndex: 0, + baseRemaining: 0n, + quoteRemaining: 0n, + ...overrides, + }; +} + +function makeMarket({ + bids, + asks, +}: { + bids: OrderView[]; + asks: OrderView[]; +}): DropsetMarketView { + return { + header: {} as DropsetMarketView["header"], + seats: [], + bids, + asks, + users: new Map(), + address: "11111111111111111111111111111111" as Address, + }; +} + +describe("marketLiquidity", () => { + it("should sum bid quoteRemaining directly", () => { + const market = makeMarket({ + bids: [ + makeOrder({ encodedPrice: PRICE_2, quoteRemaining: 100n }), + makeOrder({ encodedPrice: PRICE_2, quoteRemaining: 200n }), + ], + asks: [], + }); + const result = marketLiquidity(market); + expect(result.bidLiquidity).toBe(300n); + expect(result.askLiquidity).toBe(0n); + expect(result.total).toBe(300n); + }); + + it("should convert ask baseRemaining to quote via encoded price", () => { + // 2 asks each with 1000 base atoms at price 2.0 → 2000 quote atoms each. + const market = makeMarket({ + bids: [], + asks: [ + makeOrder({ encodedPrice: PRICE_2, baseRemaining: 1000n }), + makeOrder({ encodedPrice: PRICE_2, baseRemaining: 1000n }), + ], + }); + const result = marketLiquidity(market); + expect(result.bidLiquidity).toBe(0n); + expect(result.askLiquidity).toBe(4000n); + expect(result.total).toBe(4000n); + }); + + it("should combine bids and asks with different prices", () => { + const market = makeMarket({ + bids: [makeOrder({ encodedPrice: PRICE_2, quoteRemaining: 500n })], + asks: [ + // 200 base at price 2.0 → 400 quote + makeOrder({ encodedPrice: PRICE_2, baseRemaining: 200n }), + // 600 base at price 0.5 → 300 quote + makeOrder({ encodedPrice: PRICE_0_5, baseRemaining: 600n }), + ], + }); + const result = marketLiquidity(market); + expect(result.bidLiquidity).toBe(500n); + expect(result.askLiquidity).toBe(700n); + expect(result.total).toBe(1200n); + }); + + it("should return zero for an empty book", () => { + const market = makeMarket({ bids: [], asks: [] }); + const result = marketLiquidity(market); + expect(result.bidLiquidity).toBe(0n); + expect(result.askLiquidity).toBe(0n); + expect(result.total).toBe(0n); + }); +}); + +describe("totalLiquidity", () => { + it("should aggregate across multiple markets", () => { + const market1 = makeMarket({ + bids: [makeOrder({ encodedPrice: PRICE_2, quoteRemaining: 100n })], + asks: [makeOrder({ encodedPrice: PRICE_2, baseRemaining: 100n })], // → 200 quote + }); + const market2 = makeMarket({ + bids: [makeOrder({ encodedPrice: PRICE_0_5, quoteRemaining: 50n })], + asks: [makeOrder({ encodedPrice: PRICE_0_5, baseRemaining: 400n })], // → 200 quote + }); + const result = totalLiquidity([market1, market2]); + expect(result.bidLiquidity).toBe(150n); + expect(result.askLiquidity).toBe(400n); + expect(result.total).toBe(550n); + }); +}); diff --git a/ts-sdk/src/tests/unit/price/client-helpers.test.ts b/ts-sdk/src/tests/unit/price/client-helpers.test.ts new file mode 100644 index 00000000..27a6cfb5 --- /dev/null +++ b/ts-sdk/src/tests/unit/price/client-helpers.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "@jest/globals"; +import { Decimal } from "decimal.js"; +import { + atomsToUiAmount, + BIAS, + decimalPow10, + PriceError, + toBiasedExponent, + toOrderInfoArgs, + UNBIASED_MAX, + UNBIASED_MIN, + uiPriceToAtomsPrice, +} from "@/ts-sdk/price"; + +const biasedExponent = (unbiased: number) => toBiasedExponent(unbiased); + +describe("client helpers", () => { + // Port of `test_try_biased_exponents` in `price/src/client_helpers.rs`. + describe("toBiasedExponent", () => { + it("should convert valid unbiased exponents", () => { + expect(toBiasedExponent(UNBIASED_MIN)).toBe(UNBIASED_MIN + BIAS); + expect(toBiasedExponent(0)).toBe(BIAS); + expect(toBiasedExponent(UNBIASED_MAX)).toBe(UNBIASED_MAX + BIAS); + }); + + it("should reject out-of-range exponents", () => { + expect(() => toBiasedExponent(UNBIASED_MIN - 1)).toThrow( + PriceError.InvalidBiasedExponent, + ); + expect(() => toBiasedExponent(UNBIASED_MAX + 1)).toThrow( + PriceError.InvalidBiasedExponent, + ); + }); + }); + + // Port of `test_to_order_info_args` in `price/src/client_helpers.rs`. + describe("toOrderInfoArgs", () => { + it("should produce valid args", () => { + expect(() => + toOrderInfoArgs(new Decimal("1.5123"), 500_000n), + ).not.toThrow(); + }); + + it("should match the EUR/USD example from the Rust doctest", () => { + const baseAtoms = 500n * 10n ** 6n; + const result = toOrderInfoArgs(new Decimal("1.25"), baseAtoms); + expect(result.priceMantissa).toBe(12_500_000); + expect(result.baseScalar).toBe(5n); + expect(result.baseExponentBiased).toBe(biasedExponent(8)); + expect(result.quoteExponentBiased).toBe(biasedExponent(1)); + }); + }); + + // Port of `test_decimal_pow10` in `price/src/client_helpers.rs`. + describe("decimalPow10", () => { + it("should scale decimals by powers of 10", () => { + const check = (value: string, pow: number, expected: string) => { + expect( + decimalPow10(new Decimal(value), pow).eq(new Decimal(expected)), + ).toBe(true); + }; + + check("1.23", 2, "123"); + check("1.6923", 3, "1692.3"); + check("1.000333", 4, "10003.33"); + check("1.23", -1, "0.123"); + check("1.23", -2, "0.0123"); + check("0.05123", -9, "0.00000000005123"); + }); + }); + + // Port of `varying_decimal_pair` in `price/src/client_helpers.rs`. + describe("uiPriceToAtomsPrice", () => { + it("should scale price by 10^(quoteDecimals - baseDecimals)", () => { + const check = ( + price: string, + base: number, + quote: number, + expected: string, + ) => { + expect( + uiPriceToAtomsPrice(new Decimal(price), base, quote).eq( + new Decimal(expected), + ), + ).toBe(true); + }; + + check("1.27", 6, 6, "1.27"); + check("1.27", 5, 6, "12.7"); + check("1.27", 6, 5, "0.127"); + check("1.27", 11, 19, "127000000"); + check("1.27", 19, 11, "0.0000000127"); + }); + }); + + describe("atomsToUiAmount", () => { + it("should convert atoms to UI amount", () => { + expect(atomsToUiAmount(1_000_000n, 6).eq(new Decimal("1"))).toBe(true); + expect(atomsToUiAmount(500_000_000n, 9).eq(new Decimal("0.5"))).toBe( + true, + ); + }); + }); +}); diff --git a/ts-sdk/src/tests/unit/price/encoded-price.test.ts b/ts-sdk/src/tests/unit/price/encoded-price.test.ts new file mode 100644 index 00000000..746fde98 --- /dev/null +++ b/ts-sdk/src/tests/unit/price/encoded-price.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "@jest/globals"; +import { + decodePrice, + type EncodedPrice, + encodePrice, + toBiasedExponent, + validatePriceMantissa, +} from "@/ts-sdk/price"; + +describe("EncodedPrice", () => { + // Port of `test_zero_and_infinity` in `price/src/encoded_price.rs`. + it("should recognize zero and infinity", () => { + const zero = decodePrice(0); + const infinity = decodePrice(0xffff_ffff); + expect(zero.kind).toBe("zero"); + expect(infinity.kind).toBe("infinity"); + }); + + // Port of `round_trip_encoded_to_le_encoded` in `price/src/encoded_price.rs`. + it("should round-trip through encode and decode", () => { + const mantissa = validatePriceMantissa(12_345_678); + const biased = toBiasedExponent(1); + const encoded = encodePrice(mantissa, biased); + + const decoded = decodePrice(encoded); + expect(decoded.kind).toBe("value"); + if (decoded.kind === "value") { + expect(decoded.mantissa.value).toBe(12_345_678); + expect(decoded.biasedExponent).toBe(biased); + } + }); + + // Port of `price_priority` in `price/src/encoded_price.rs`. + it("should maintain price priority ordering", () => { + const prices: EncodedPrice[] = [ + 10_000_000, 20_000_000, 30_000_000, 40_000_000, + ].map((m) => encodePrice(validatePriceMantissa(m), toBiasedExponent(0))); + const [p1, p2, p3, p4] = prices; + + // Bids: higher price = higher priority. + expect(p4).toBeGreaterThan(p3); + expect(p3).toBeGreaterThan(p2); + expect(p2).toBeGreaterThan(p1); + + // Asks: lower price = higher priority. + expect(p1).toBeLessThan(p2); + expect(p2).toBeLessThan(p3); + expect(p3).toBeLessThan(p4); + }); +}); diff --git a/ts-sdk/src/tests/unit/price/ensure-unsigned-integer.test.ts b/ts-sdk/src/tests/unit/price/ensure-unsigned-integer.test.ts new file mode 100644 index 00000000..a6af9ff1 --- /dev/null +++ b/ts-sdk/src/tests/unit/price/ensure-unsigned-integer.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "@jest/globals"; +import { + ensureU8, + ensureU16, + ensureU32, + ensureU64, + RustTypeError, + U8_MAX, + U16_MAX, + U32_MAX, + U64_MAX, +} from "@/ts-sdk/rust-types"; + +type EnsureFn = (n: number | bigint) => unknown; + +function testUnsignedInteger( + name: string, + ensure: EnsureFn, + max: number | bigint, +) { + const errorName = + RustTypeError[`Invalid${name}` as keyof typeof RustTypeError]; + const isBigint = typeof max === "bigint"; + + describe(name, () => { + it("accepts 0", () => { + expect(() => ensure(0)).not.toThrow(); + }); + + it("accepts 0 as bigint", () => { + expect(() => ensure(0n)).not.toThrow(); + }); + + it("accepts max", () => { + expect(() => ensure(max)).not.toThrow(); + }); + + it("accepts mid-range value", () => { + const mid = isBigint + ? (max as bigint) / 2n + : Math.floor((max as number) / 2); + expect(() => ensure(mid)).not.toThrow(); + }); + + it("rejects max + 1", () => { + const overflow = isBigint ? (max as bigint) + 1n : (max as number) + 1; + expect(() => ensure(overflow)).toThrow(errorName); + }); + + it("rejects negative numbers", () => { + expect(() => ensure(-1)).toThrow(errorName); + expect(() => ensure(-1n)).toThrow(errorName); + }); + + if (!isBigint) { + it("rejects floats", () => { + expect(() => ensure(1.5)).toThrow(errorName); + expect(() => ensure(0.1)).toThrow(errorName); + }); + + it("rejects NaN", () => { + expect(() => ensure(NaN)).toThrow(errorName); + }); + + it("rejects Infinity", () => { + expect(() => ensure(Infinity)).toThrow(errorName); + expect(() => ensure(-Infinity)).toThrow(errorName); + }); + } + + it("returns the correct value", () => { + const result = ensure(1); + expect(result).toStrictEqual(isBigint ? 1n : 1); + }); + }); +} + +testUnsignedInteger("U8", ensureU8, U8_MAX); +testUnsignedInteger("U16", ensureU16, U16_MAX); +testUnsignedInteger("U32", ensureU32, U32_MAX); +testUnsignedInteger("U64", ensureU64, U64_MAX); diff --git a/ts-sdk/src/tests/unit/price/lib.test.ts b/ts-sdk/src/tests/unit/price/lib.test.ts new file mode 100644 index 00000000..e06fdbeb --- /dev/null +++ b/ts-sdk/src/tests/unit/price/lib.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "@jest/globals"; +import { Decimal } from "decimal.js"; +import { + BIAS, + decodedPriceToDecimal, + decodePrice, + MANTISSA_DIGITS_LOWER_BOUND, + MANTISSA_DIGITS_UPPER_BOUND, + orderAtPrice, + PriceError, + toBiasedExponent, + toOrderInfo, + UNBIASED_MAX, +} from "@/ts-sdk/price"; + +const biasedExponent = (unbiased: number) => toBiasedExponent(unbiased); + +describe("toOrderInfo", () => { + // Port of `happy_path_simple_price` in `price/src/lib.rs`. + it("should compute a simple price", () => { + const order = toOrderInfo({ + priceMantissa: 12_340_000, + baseScalar: 1n, + baseExponentBiased: biasedExponent(0), + quoteExponentBiased: biasedExponent(-4), + }); + expect(order.baseAtoms).toBe(1n); + expect(order.quoteAtoms).toBe(1234n); + + const decoded = decodePrice(order.encodedPrice); + const decimalPrice = decodedPriceToDecimal(decoded); + expect(decimalPrice.eq(new Decimal("1234"))).toBe(true); + }); + + // Port of `price_with_max_sig_digits` in `price/src/lib.rs`. + it("should handle max significant digits", () => { + const order = toOrderInfo({ + priceMantissa: 12_345_678, + baseScalar: 1n, + baseExponentBiased: biasedExponent(0), + quoteExponentBiased: biasedExponent(0), + }); + expect(order.baseAtoms).toBe(1n); + expect(order.quoteAtoms).toBe(12_345_678n); + + const decoded = decodePrice(order.encodedPrice); + const decimalPrice = decodedPriceToDecimal(decoded); + expect(decimalPrice.eq(new Decimal("12345678"))).toBe(true); + }); + + // Port of `decimal_price` in `price/src/lib.rs`. + it("should handle a decimal price", () => { + const mantissa = 12_345_678; + const order = toOrderInfo({ + priceMantissa: mantissa, + baseScalar: 1n, + baseExponentBiased: biasedExponent(8), + quoteExponentBiased: biasedExponent(0), + }); + expect(order.quoteAtoms).toBe(12_345_678n); + expect(order.baseAtoms).toBe(100_000_000n); + + const decoded = decodePrice(order.encodedPrice); + expect(decoded.kind).toBe("value"); + if (decoded.kind === "value") { + expect(decoded.mantissa.value).toBe(mantissa); + } + const decimalPrice = decodedPriceToDecimal(decoded); + expect(decimalPrice.eq(new Decimal("0.12345678"))).toBe(true); + }); + + // Port of `order_at_price_encoded_price_equals_mantissa` in `price/src/lib.rs`. + it("should produce encoded price equal to mantissa for order_at_price args", () => { + for (const mantissa of [ + MANTISSA_DIGITS_LOWER_BOUND, + 50_000_000, + MANTISSA_DIGITS_UPPER_BOUND, + ]) { + const order = toOrderInfo({ + priceMantissa: mantissa, + baseScalar: 1n, + baseExponentBiased: biasedExponent(UNBIASED_MAX), + quoteExponentBiased: biasedExponent(-1), + }); + expect(order.encodedPrice).toBe(mantissa); + } + }); + + // Port of `ensure_exponent_underflow` in `price/src/lib.rs`. + it("should throw ExponentUnderflow when quote exp is too small relative to base", () => { + expect(() => + toOrderInfo({ + priceMantissa: 10_000_000, + baseScalar: 1n, + baseExponentBiased: BIAS + 1, + quoteExponentBiased: 0, + }), + ).toThrow(PriceError.ExponentUnderflow); + + expect(() => + toOrderInfo({ + priceMantissa: 10_000_000, + baseScalar: 1n, + baseExponentBiased: BIAS, + quoteExponentBiased: 0, + }), + ).not.toThrow(); + }); + + // Port of `order_at_price_encoded_price_equals_mantissa` in `price/src/lib.rs`. + // (using `orderAtPrice` helper instead of inline args) + it("should produce encoded price equal to mantissa via orderAtPrice", () => { + for (const mantissa of [ + MANTISSA_DIGITS_LOWER_BOUND, + 50_000_000, + MANTISSA_DIGITS_UPPER_BOUND, + ]) { + const order = toOrderInfo(orderAtPrice(mantissa)); + expect(order.encodedPrice).toBe(mantissa); + } + }); +}); diff --git a/ts-sdk/src/tests/unit/price/utils.test.ts b/ts-sdk/src/tests/unit/price/utils.test.ts new file mode 100644 index 00000000..4664342a --- /dev/null +++ b/ts-sdk/src/tests/unit/price/utils.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "@jest/globals"; +import { Decimal } from "decimal.js"; +import { baseFromQuote, quoteFromBase } from "@/ts-sdk/price"; + +describe("token conversion", () => { + describe("quoteFromBase / baseFromQuote with bigint (atoms)", () => { + it("should convert at price = 2 (simple)", () => { + const base = 100_000n; + const price = new Decimal("2"); + const quote = quoteFromBase(base, price); + expect(quote).toBe(200_000n); + expect(baseFromQuote(quote, price)).toBe(base); + }); + + it("should convert at price = 0.5 (fractional)", () => { + const base = 200_000n; + const price = new Decimal("0.5"); + const quote = quoteFromBase(base, price); + expect(quote).toBe(100_000n); + expect(baseFromQuote(quote, price)).toBe(base); + }); + + it("should truncate toward zero on indivisible conversions", () => { + expect(quoteFromBase(7n, new Decimal("3"))).toBe(21n); + expect(baseFromQuote(30n, new Decimal("3"))).toBe(10n); + // 10 base at price 0.33 = 3.3 → truncated to 3. + expect(quoteFromBase(10n, new Decimal("0.33"))).toBe(3n); + // 10 quote at price 3 = 3.333... → truncated to 3. + expect(baseFromQuote(10n, new Decimal("3"))).toBe(3n); + }); + + it("should handle SOL/USDC-like atoms (9 vs 6 decimals, price ~150)", () => { + // 1 SOL = 1_000_000_000 lamports, 1 USDC = 1_000_000 micro-USDC. + // UI price = 150 USDC/SOL. + // Atoms price = 150 * 10^(6-9) = 0.15. + const atomsPrice = new Decimal("0.15"); + const baseLamports = 1_000_000_000n; // 1 SOL + const expectedQuoteMicro = 150_000_000n; // 150 USDC + + expect(quoteFromBase(baseLamports, atomsPrice)).toBe(expectedQuoteMicro); + expect(baseFromQuote(expectedQuoteMicro, atomsPrice)).toBe(baseLamports); + }); + + it("should handle BTC/USDC-like atoms (8 vs 6 decimals, price ~60000)", () => { + // 1 BTC = 100_000_000 sats, 1 USDC = 1_000_000 micro-USDC. + // UI price = 60000 USDC/BTC. + // Atoms price = 60000 * 10^(6-8) = 600. + const atomsPrice = new Decimal("600"); + const baseSats = 100_000_000n; // 1 BTC + const expectedQuoteMicro = 60_000_000_000n; // 60000 USDC + + expect(quoteFromBase(baseSats, atomsPrice)).toBe(expectedQuoteMicro); + expect(baseFromQuote(expectedQuoteMicro, atomsPrice)).toBe(baseSats); + }); + + it("should handle tiny fractional price (sub-penny token)", () => { + // Token at $0.000001 with 9 decimals each. + const atomsPrice = new Decimal("0.000001"); + const base = 1_000_000_000n; + const expectedQuote = 1000n; + + expect(quoteFromBase(base, atomsPrice)).toBe(expectedQuote); + expect(baseFromQuote(expectedQuote, atomsPrice)).toBe(base); + }); + }); + + describe("quoteFromBase / baseFromQuote with Decimal (UI amounts)", () => { + it("should convert UI amounts at price = 150", () => { + const base = new Decimal("2.5"); // 2.5 SOL + const price = new Decimal("150"); // 150 USDC/SOL + const quote = quoteFromBase(base, price); + expect(quote.eq(new Decimal("375"))).toBe(true); + expect(baseFromQuote(quote, price).eq(base)).toBe(true); + }); + + it("should preserve full decimal precision", () => { + const base = new Decimal("0.123456789"); + const price = new Decimal("60000.50"); + const quote = quoteFromBase(base, price); + expect(baseFromQuote(quote, price).eq(base)).toBe(true); + }); + }); +}); diff --git a/ts-sdk/src/tests/unit/price/validated-mantissa.test.ts b/ts-sdk/src/tests/unit/price/validated-mantissa.test.ts new file mode 100644 index 00000000..1f375229 --- /dev/null +++ b/ts-sdk/src/tests/unit/price/validated-mantissa.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "@jest/globals"; +import { Decimal } from "decimal.js"; +import { + MANTISSA_DIGITS_LOWER_BOUND, + MANTISSA_DIGITS_UPPER_BOUND, + normalizePriceMantissa, + PriceError, + validatePriceMantissa, +} from "@/ts-sdk/price"; + +describe("ValidatedPriceMantissa", () => { + // Port of `valid_mantissas` in `price/src/validated_mantissa.rs`. + it("should accept valid mantissas", () => { + for (const m of [ + MANTISSA_DIGITS_LOWER_BOUND, + MANTISSA_DIGITS_LOWER_BOUND + 1, + MANTISSA_DIGITS_UPPER_BOUND, + MANTISSA_DIGITS_UPPER_BOUND - 1, + ]) { + const v = validatePriceMantissa(m); + expect(v.value).toBe(m); + } + }); + + // Port of `invalid_mantissas` in `price/src/validated_mantissa.rs`. + it("should reject invalid mantissas", () => { + expect(() => + validatePriceMantissa(MANTISSA_DIGITS_LOWER_BOUND - 1), + ).toThrow(PriceError.InvalidPriceMantissa); + expect(() => + validatePriceMantissa(MANTISSA_DIGITS_UPPER_BOUND + 1), + ).toThrow(PriceError.InvalidPriceMantissa); + }); + + // Port of `test_normalize_values` in `price/src/validated_mantissa.rs`. + it("should normalize prices into mantissa + scale", () => { + const check = ( + price: string, + expectedMantissa: number, + expectedScale: number, + ) => { + const { mantissa, scale } = normalizePriceMantissa(new Decimal(price)); + expect(mantissa.value).toBe(expectedMantissa); + expect(scale).toBe(expectedScale); + }; + + check("1.32", 13_200_000, -7); + check("0.95123", 95_123_000, -8); + check("123456789", 12_345_678, 1); + check("78.12300001", 78_123_000, -6); + check("0.000000000000012345678", 12_345_678, -21); + check("0.000000000001", 10_000_000, -19); + }); + + it("should reject zero and negative prices", () => { + expect(() => normalizePriceMantissa(new Decimal("0"))).toThrow( + PriceError.InvalidPriceMantissa, + ); + expect(() => normalizePriceMantissa(new Decimal("0.000000"))).toThrow( + PriceError.InvalidPriceMantissa, + ); + expect(() => normalizePriceMantissa(new Decimal("-1"))).toThrow( + PriceError.InvalidPriceMantissa, + ); + expect(() => + normalizePriceMantissa(new Decimal("-0.0000000000001")), + ).toThrow(PriceError.InvalidPriceMantissa); + }); +}); diff --git a/ts-sdk/src/types/index.ts b/ts-sdk/src/types/index.ts index f94f47a7..82993c3c 100644 --- a/ts-sdk/src/types/index.ts +++ b/ts-sdk/src/types/index.ts @@ -1,3 +1,7 @@ +import type { Address } from "@solana/kit"; +import type { MarketViewAll } from "../dropset-interface"; +import type { MarketAccount } from "../generated"; + /** * Flatten a type to remove any nested properties from unions and intersections. * {@link https://twitter.com/mattpocockuk/status/1622730173446557697} @@ -5,3 +9,15 @@ export type Flatten = { [K in keyof T]: T[K] } & NonNullable; export type SectorIndex = number; + +export type DropsetMarketAccount = Flatten< + { + address: Address; + } & MarketAccount +>; + +export type DropsetMarketView = Flatten< + { + address: Address; + } & MarketViewAll +>; diff --git a/ts-sdk/src/utils/index.ts b/ts-sdk/src/utils/index.ts index 02953256..fad47f3c 100644 --- a/ts-sdk/src/utils/index.ts +++ b/ts-sdk/src/utils/index.ts @@ -8,9 +8,17 @@ import { } from "@solana/kit"; import type { createHttpTransport } from "@solana/rpc-transport-http"; -import { LOCALNET_URL, MARKET_SEED_STR } from "@/const"; -import { DROPSET_PROGRAM_ADDRESS, getMarketAccountDecoder } from "@/generated"; -import type { Flatten } from "../types"; +import { LOCALNET_URL, MARKET_SEED_STR } from "@/ts-sdk/const"; +import { + DROPSET_PROGRAM_ADDRESS, + getMarketAccountDecoder, +} from "@/ts-sdk/generated"; +import { toMarketViewAll } from "../dropset-interface"; +import type { + DropsetMarketAccount, + DropsetMarketView, + Flatten, +} from "../types"; type HttpTransportConfig = Flatten[0]>; @@ -33,11 +41,11 @@ export function getRpcClient(args?: RpcClientArgs) { } /** - * Gets the dropset market accounts owned by the dropset program. + * Fetches the dropset market accounts owned by the dropset program. */ -export async function getDropsetMarkets( +export async function fetchDropsetMarketAccounts( rpcClient: ReturnType, -) { +): Promise { const markets = await rpcClient .getProgramAccounts(DROPSET_PROGRAM_ADDRESS, { encoding: "base64" }) .send(); @@ -46,10 +54,25 @@ export async function getDropsetMarkets( return markets.map( (market) => - [ - market.pubkey, - decoder.decode(Buffer.from(market.account.data[0], "base64")), - ] as const, + ({ + address: market.pubkey, + ...decoder.decode(Buffer.from(market.account.data[0], "base64")), + }) as const, + ); +} + +/** + * Fetches the dropset market accounts owned by the dropset program and converts them into + * more ergonomic {@link DropsetMarketView} types. + */ +export async function fetchDropsetMarketViews( + rpcClient: ReturnType, +): Promise { + return (await fetchDropsetMarketAccounts(rpcClient)).map( + ({ address, ...market }) => ({ + address, + ...toMarketViewAll(market), + }), ); } @@ -72,3 +95,5 @@ export async function deriveMarketAddress( ], }); } + +export * from "./liquidity"; diff --git a/ts-sdk/src/utils/liquidity.ts b/ts-sdk/src/utils/liquidity.ts new file mode 100644 index 00000000..12471a31 --- /dev/null +++ b/ts-sdk/src/utils/liquidity.ts @@ -0,0 +1,43 @@ +import type { OrderView } from "../dropset-interface"; +import { encodedU32ToDecimal, quoteFromBase } from "../price"; +import type { DropsetMarketView } from "../types"; + +/** Sum `quoteRemaining` across all bids in a market. */ +function sumBidQuote(bids: OrderView[]): bigint { + return bids.reduce((total, bid) => total + bid.quoteRemaining, 0n); +} + +/** Sum asks converted to quote: `baseRemaining * decodedPrice` for each ask. */ +function sumAskQuoteEquivalent(asks: OrderView[]): bigint { + return asks.reduce((total, ask) => { + const price = encodedU32ToDecimal(ask.encodedPrice); + return total + quoteFromBase(ask.baseRemaining, price); + }, 0n); +} + +/** Total liquidity (in quote atoms) for a single market across both sides of the book. */ +export function marketLiquidity(market: DropsetMarketView): { + bidLiquidity: bigint; + askLiquidity: bigint; + total: bigint; +} { + const bidLiquidity = sumBidQuote(market.bids); + const askLiquidity = sumAskQuoteEquivalent(market.asks); + return { bidLiquidity, askLiquidity, total: bidLiquidity + askLiquidity }; +} + +/** Aggregate liquidity (in quote atoms) across multiple markets. */ +export function totalLiquidity(markets: DropsetMarketView[]): { + bidLiquidity: bigint; + askLiquidity: bigint; + total: bigint; +} { + let bidLiquidity = 0n; + let askLiquidity = 0n; + for (const market of markets) { + const m = marketLiquidity(market); + bidLiquidity += m.bidLiquidity; + askLiquidity += m.askLiquidity; + } + return { bidLiquidity, askLiquidity, total: bidLiquidity + askLiquidity }; +} diff --git a/ts-sdk/tsconfig.json b/ts-sdk/tsconfig.json index 0772e366..8dad5642 100644 --- a/ts-sdk/tsconfig.json +++ b/ts-sdk/tsconfig.json @@ -18,7 +18,7 @@ "noImplicitAny": true, "noImplicitThis": true, "paths": { - "@/*": ["./src/*"] + "@/ts-sdk/*": ["./src/*"] }, "preserveWatchOutput": true, "resolveJsonModule": true,