From 5e8fb4a6a88e87ab089391dde75e41a65c75f195 Mon Sep 17 00:00:00 2001 From: Matt <90358481+xbtmatt@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:58:39 -0700 Subject: [PATCH 01/12] Add `price` utilities to the `ts-sdk` --- frontend/tsconfig.json | 4 +- pnpm-workspace.yaml | 3 +- ts-sdk/package.json | 5 +- ts-sdk/src/const.ts | 2 +- .../src/dropset-interface/market-view-all.ts | 8 +- ts-sdk/src/index.ts | 1 + ts-sdk/src/price/client-helpers.ts | 94 +++++++++++++ ts-sdk/src/price/decoded-price.ts | 52 +++++++ ts-sdk/src/price/encoded-price.ts | 30 ++++ ts-sdk/src/price/error.ts | 9 ++ ts-sdk/src/price/index.ts | 7 + ts-sdk/src/price/lib.ts | 89 ++++++++++++ ts-sdk/src/price/utils.ts | 53 +++++++ ts-sdk/src/price/validated-mantissa.ts | 71 ++++++++++ ts-sdk/src/tests/e2e/dropset-accounts.test.ts | 34 +++-- .../tests/unit/derive-market-account.test.ts | 4 +- .../unit/deserialize-market-account.test.ts | 6 +- ts-sdk/src/tests/unit/liquidity.test.ts | 129 ++++++++++++++++++ .../tests/unit/price/client-helpers.test.ts | 104 ++++++++++++++ .../tests/unit/price/encoded-price.test.ts | 50 +++++++ ts-sdk/src/tests/unit/price/lib.test.ts | 122 +++++++++++++++++ ts-sdk/src/tests/unit/price/utils.test.ts | 83 +++++++++++ .../unit/price/validated-mantissa.test.ts | 69 ++++++++++ ts-sdk/src/types/index.ts | 16 +++ ts-sdk/src/utils/index.ts | 45 ++++-- ts-sdk/src/utils/liquidity.ts | 43 ++++++ ts-sdk/tsconfig.json | 2 +- 27 files changed, 1100 insertions(+), 35 deletions(-) create mode 100644 ts-sdk/src/price/client-helpers.ts create mode 100644 ts-sdk/src/price/decoded-price.ts create mode 100644 ts-sdk/src/price/encoded-price.ts create mode 100644 ts-sdk/src/price/error.ts create mode 100644 ts-sdk/src/price/index.ts create mode 100644 ts-sdk/src/price/lib.ts create mode 100644 ts-sdk/src/price/utils.ts create mode 100644 ts-sdk/src/price/validated-mantissa.ts create mode 100644 ts-sdk/src/tests/unit/liquidity.test.ts create mode 100644 ts-sdk/src/tests/unit/price/client-helpers.test.ts create mode 100644 ts-sdk/src/tests/unit/price/encoded-price.test.ts create mode 100644 ts-sdk/src/tests/unit/price/lib.test.ts create mode 100644 ts-sdk/src/tests/unit/price/utils.test.ts create mode 100644 ts-sdk/src/tests/unit/price/validated-mantissa.test.ts create mode 100644 ts-sdk/src/utils/liquidity.ts 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/pnpm-workspace.yaml b/pnpm-workspace.yaml index c58b50b0..35e4af08 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,5 +13,6 @@ catalog: "@types/node": ^24 "jest": ^29.7.0 "ts-jest": ^29.4.6 - "typescript": ^5 + "typescript": ^5.9 + "decimal.js": ^10.6.0 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..ae43cda4 100644 --- a/ts-sdk/src/index.ts +++ b/ts-sdk/src/index.ts @@ -1,4 +1,5 @@ export * from "./dropset-interface"; export * from "./generated"; +export * from "./price"; 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..95b3cda9 --- /dev/null +++ b/ts-sdk/src/price/client-helpers.ts @@ -0,0 +1,94 @@ +import { Decimal } from "decimal.js"; +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): number { + if (unbiased < UNBIASED_MIN || unbiased > UNBIASED_MAX) { + throw new Error(PriceError.InvalidBiasedExponent); + } + return unbiased + BIAS; +} + +/** Port of `atoms_to_ui_amount` in `price/src/client_helpers.rs`. */ +export function atomsToUiAmount( + atomsAmount: bigint, + mintDecimals: number, +): Decimal { + return decimalPow10(new Decimal(atomsAmount.toString()), -mintDecimals); +} + +/** + * 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, + quoteDecimals: number, +): Decimal { + return decimalPow10(uiPrice, quoteDecimals - baseDecimals); +} + +/** Port of `try_encoded_u32_to_decoded_decimal` in `price/src/client_helpers.rs`. */ +export function encodedU32ToDecimal(encodedU32: number): 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: number; + baseScalar: bigint; + baseExponentBiased: number; + quoteExponentBiased: number; +} { + const { mantissa, scale: priceExponent } = normalizePriceMantissa(price); + + if (orderSizeBaseAtoms === 0n) throw new Error(PriceError.AmountCannotBeZero); + + const { scalar: baseScalar, pow: baseExponentUnbiased } = + getSigFigs(orderSizeBaseAtoms); + const quoteExponentUnbiased = priceExponent + baseExponentUnbiased; + + return { + priceMantissa: mantissa.value, + 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..86c91b51 --- /dev/null +++ b/ts-sdk/src/price/decoded-price.ts @@ -0,0 +1,52 @@ +import { Decimal } from "decimal.js"; +import { decimalPow10 } from "./client-helpers"; +import { + ENCODED_PRICE_INFINITY, + ENCODED_PRICE_ZERO, + type EncodedPrice, +} 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: number; + mantissa: ValidatedPriceMantissa; + }; + +/** Port of `DecodedPrice::try_from(EncodedPrice)` in `price/src/decoded_price.rs`. */ +export function decodePrice(encoded: EncodedPrice): DecodedPrice { + if (encoded === ENCODED_PRICE_ZERO) return { kind: "zero" }; + if (encoded === ENCODED_PRICE_INFINITY) return { kind: "infinity" }; + + const biasedExponent = encoded >>> PRICE_MANTISSA_BITS; + const mantissa = validatePriceMantissa(encoded & 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..9c4dc35d --- /dev/null +++ b/ts-sdk/src/price/encoded-price.ts @@ -0,0 +1,30 @@ +import { PRICE_MANTISSA_BITS } from "./lib"; +import type { ValidatedPriceMantissa } from "./validated-mantissa"; + +const ENCODED_PRICE_INFINITY = 0xffff_ffff; +const ENCODED_PRICE_ZERO = 0; + +export { ENCODED_PRICE_INFINITY, ENCODED_PRICE_ZERO }; + +/** + * An encoded price packed into a u32: `[exponent_bits | mantissa_bits]`. + * + * Port of `EncodedPrice` in `price/src/encoded_price.rs`. + */ +export type EncodedPrice = number; + +/** Port of `EncodedPrice::new` in `price/src/encoded_price.rs`. */ +export function encodePrice( + mantissa: ValidatedPriceMantissa, + biasedExponent: number, +): EncodedPrice { + return ((biasedExponent << PRICE_MANTISSA_BITS) | mantissa.value) >>> 0; +} + +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..ad8257b3 --- /dev/null +++ b/ts-sdk/src/price/lib.ts @@ -0,0 +1,89 @@ +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 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 { + if (biasedExponent === BIAS) return value; + + if (biasedExponent < 0 || biasedExponent > 31) { + throw new Error(PriceError.InvalidBiasedExponent); + } + + const unbiased = biasedExponent - 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; + baseScalar: bigint; + baseExponentBiased: number; + quoteExponentBiased: number; +}): { + encodedPrice: EncodedPrice; + baseAtoms: bigint; + quoteAtoms: bigint; +} { + const mantissa = validatePriceMantissa(args.priceMantissa); + + const baseAtoms = pow10Bigint(args.baseScalar, args.baseExponentBiased); + const quoteAtoms = pow10Bigint( + BigInt(mantissa.value) * args.baseScalar, + args.quoteExponentBiased, + ); + + // Re-bias: price_exponent = quote_exponent_biased + BIAS - base_exponent_biased + const rebiased = args.quoteExponentBiased + BIAS - args.baseExponentBiased; + 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): { + priceMantissa: number; + baseScalar: bigint; + baseExponentBiased: number; + quoteExponentBiased: number; +} { + return { + priceMantissa, + baseScalar: 1n, + baseExponentBiased: UNBIASED_MAX + BIAS, + quoteExponentBiased: 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..7ebc967d --- /dev/null +++ b/ts-sdk/src/price/utils.ts @@ -0,0 +1,53 @@ +import { Decimal } from "decimal.js"; + +/** + * 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") { + return BigInt( + new Decimal(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") { + return BigInt( + new Decimal(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..f3c9d2ef --- /dev/null +++ b/ts-sdk/src/price/validated-mantissa.ts @@ -0,0 +1,71 @@ +import { Decimal } from "decimal.js"; +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: number; +}; + +/** Port of `ValidatedPriceMantissa::try_from` in `price/src/validated_mantissa.rs`. */ +export function validatePriceMantissa( + mantissa: number, +): ValidatedPriceMantissa { + if ( + mantissa < MANTISSA_DIGITS_LOWER_BOUND || + mantissa > MANTISSA_DIGITS_UPPER_BOUND + ) { + throw new Error(PriceError.InvalidPriceMantissa); + } + return { + __brand: "ValidatedPriceMantissa", + value: mantissa, + } 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)) { + 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/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/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, From 302f9a6cfe6a60febc2414c3234687ccf5fe7268 Mon Sep 17 00:00:00 2001 From: Matt <90358481+xbtmatt@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:05:42 -0700 Subject: [PATCH 02/12] Organize pnpm-workspace.yaml --- pnpm-workspace.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 35e4af08..4e5059d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,8 +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.9 - "decimal.js": ^10.6.0 From 85d62cf58f32e87555e4a9e37790e6dafb5a6afb Mon Sep 17 00:00:00 2001 From: Matt <90358481+xbtmatt@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:00:36 -0700 Subject: [PATCH 03/12] Add unsigned integer branded types and tests, use them as inputs to `price` lib functions --- ts-sdk/src/index.ts | 1 + ts-sdk/src/price/client-helpers.ts | 26 +++--- ts-sdk/src/price/decoded-price.ts | 20 +++-- ts-sdk/src/price/encoded-price.ts | 19 +++-- ts-sdk/src/price/lib.ts | 41 ++++++---- ts-sdk/src/price/validated-mantissa.ts | 13 ++- ts-sdk/src/rust-types/error.ts | 6 ++ ts-sdk/src/rust-types/index.ts | 5 ++ ts-sdk/src/rust-types/u16.ts | 17 ++++ ts-sdk/src/rust-types/u32.ts | 17 ++++ ts-sdk/src/rust-types/u64.ts | 17 ++++ ts-sdk/src/rust-types/u8.ts | 17 ++++ .../price/ensure-unsigned-integer.test.ts | 81 +++++++++++++++++++ 13 files changed, 226 insertions(+), 54 deletions(-) create mode 100644 ts-sdk/src/rust-types/error.ts create mode 100644 ts-sdk/src/rust-types/index.ts create mode 100644 ts-sdk/src/rust-types/u16.ts create mode 100644 ts-sdk/src/rust-types/u32.ts create mode 100644 ts-sdk/src/rust-types/u64.ts create mode 100644 ts-sdk/src/rust-types/u8.ts create mode 100644 ts-sdk/src/tests/unit/price/ensure-unsigned-integer.test.ts diff --git a/ts-sdk/src/index.ts b/ts-sdk/src/index.ts index ae43cda4..c203672e 100644 --- a/ts-sdk/src/index.ts +++ b/ts-sdk/src/index.ts @@ -1,5 +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 index 95b3cda9..ab13889d 100644 --- a/ts-sdk/src/price/client-helpers.ts +++ b/ts-sdk/src/price/client-helpers.ts @@ -1,4 +1,5 @@ import { Decimal } from "decimal.js"; +import { ensureU8, type U8, type U32 } from "../rust-types"; import { decodedPriceToDecimal, decodePrice } from "./decoded-price"; import { PriceError } from "./error"; import { @@ -15,19 +16,20 @@ export function decimalPow10(value: Decimal, pow: number): Decimal { } /** Port of `try_to_biased_exponent` in `price/src/client_helpers.rs`. */ -export function toBiasedExponent(unbiased: number): number { +export function toBiasedExponent(unbiased: number): U8 { if (unbiased < UNBIASED_MIN || unbiased > UNBIASED_MAX) { throw new Error(PriceError.InvalidBiasedExponent); } - return unbiased + BIAS; + return ensureU8(unbiased + BIAS); } /** Port of `atoms_to_ui_amount` in `price/src/client_helpers.rs`. */ export function atomsToUiAmount( atomsAmount: bigint, - mintDecimals: number, + mintDecimals: number | bigint, ): Decimal { - return decimalPow10(new Decimal(atomsAmount.toString()), -mintDecimals); + const dec = ensureU8(mintDecimals); + return decimalPow10(new Decimal(atomsAmount.toString()), -dec); } /** @@ -40,14 +42,16 @@ export function atomsToUiAmount( */ export function uiPriceToAtomsPrice( uiPrice: Decimal, - baseDecimals: number, - quoteDecimals: number, + baseDecimals: number | bigint, + quoteDecimals: number | bigint, ): Decimal { - return decimalPow10(uiPrice, quoteDecimals - baseDecimals); + 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): Decimal { +export function encodedU32ToDecimal(encodedU32: number | bigint): Decimal { return decodedPriceToDecimal(decodePrice(encodedU32)); } @@ -72,10 +76,10 @@ export function toOrderInfoArgs( price: Decimal, orderSizeBaseAtoms: bigint, ): { - priceMantissa: number; + priceMantissa: U32; baseScalar: bigint; - baseExponentBiased: number; - quoteExponentBiased: number; + baseExponentBiased: U8; + quoteExponentBiased: U8; } { const { mantissa, scale: priceExponent } = normalizePriceMantissa(price); diff --git a/ts-sdk/src/price/decoded-price.ts b/ts-sdk/src/price/decoded-price.ts index 86c91b51..3fed1a55 100644 --- a/ts-sdk/src/price/decoded-price.ts +++ b/ts-sdk/src/price/decoded-price.ts @@ -1,10 +1,7 @@ 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, - type EncodedPrice, -} from "./encoded-price"; +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 { @@ -22,17 +19,18 @@ export type DecodedPrice = | { kind: "infinity" } | { kind: "value"; - biasedExponent: number; + biasedExponent: U8; mantissa: ValidatedPriceMantissa; }; /** Port of `DecodedPrice::try_from(EncodedPrice)` in `price/src/decoded_price.rs`. */ -export function decodePrice(encoded: EncodedPrice): DecodedPrice { - if (encoded === ENCODED_PRICE_ZERO) return { kind: "zero" }; - if (encoded === ENCODED_PRICE_INFINITY) return { kind: "infinity" }; +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 = encoded >>> PRICE_MANTISSA_BITS; - const mantissa = validatePriceMantissa(encoded & PRICE_MANTISSA_MASK); + const biasedExponent = ensureU8(v >>> PRICE_MANTISSA_BITS); + const mantissa = validatePriceMantissa(v & PRICE_MANTISSA_MASK); return { kind: "value", biasedExponent, mantissa }; } diff --git a/ts-sdk/src/price/encoded-price.ts b/ts-sdk/src/price/encoded-price.ts index 9c4dc35d..a5dfd97b 100644 --- a/ts-sdk/src/price/encoded-price.ts +++ b/ts-sdk/src/price/encoded-price.ts @@ -1,24 +1,27 @@ +import { ensureU8, type U32, U32_MAX } from "../rust-types"; import { PRICE_MANTISSA_BITS } from "./lib"; import type { ValidatedPriceMantissa } from "./validated-mantissa"; -const ENCODED_PRICE_INFINITY = 0xffff_ffff; -const ENCODED_PRICE_ZERO = 0; - -export { ENCODED_PRICE_INFINITY, ENCODED_PRICE_ZERO }; - /** * An encoded price packed into a u32: `[exponent_bits | mantissa_bits]`. * * Port of `EncodedPrice` in `price/src/encoded_price.rs`. */ -export type EncodedPrice = number; +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, + biasedExponent: number | bigint, ): EncodedPrice { - return ((biasedExponent << PRICE_MANTISSA_BITS) | mantissa.value) >>> 0; + const exp = ensureU8(biasedExponent); + return (((exp << PRICE_MANTISSA_BITS) | mantissa.value) >>> + 0) as EncodedPrice; } export function isEncodedPriceInfinity(encoded: EncodedPrice): boolean { diff --git a/ts-sdk/src/price/lib.ts b/ts-sdk/src/price/lib.ts index ad8257b3..484e0446 100644 --- a/ts-sdk/src/price/lib.ts +++ b/ts-sdk/src/price/lib.ts @@ -1,3 +1,4 @@ +import { ensureU8, type U8, type U32 } from "../rust-types"; import { type EncodedPrice, encodePrice } from "./encoded-price"; import { PriceError } from "./error"; import { @@ -18,14 +19,18 @@ 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 { - if (biasedExponent === BIAS) return value; +export function pow10Bigint( + value: bigint, + biasedExponent: number | bigint, +): bigint { + const exp = ensureU8(biasedExponent); + if (exp === BIAS) return value; - if (biasedExponent < 0 || biasedExponent > 31) { + if (exp > 31) { throw new Error(PriceError.InvalidBiasedExponent); } - const unbiased = biasedExponent - BIAS; + const unbiased = exp - BIAS; if (unbiased < 0) { return value / 10n ** BigInt(-unbiased); } @@ -38,25 +43,27 @@ export function pow10Bigint(value: bigint, biasedExponent: number): bigint { * Port of `to_order_info` in `price/src/lib.rs`. */ export function toOrderInfo(args: { - priceMantissa: number; + priceMantissa: number | bigint; baseScalar: bigint; - baseExponentBiased: number; - quoteExponentBiased: number; + baseExponentBiased: number | bigint; + quoteExponentBiased: number | bigint; }): { encodedPrice: EncodedPrice; baseAtoms: bigint; quoteAtoms: bigint; } { const mantissa = validatePriceMantissa(args.priceMantissa); + const baseExp = ensureU8(args.baseExponentBiased); + const quoteExp = ensureU8(args.quoteExponentBiased); - const baseAtoms = pow10Bigint(args.baseScalar, args.baseExponentBiased); + const baseAtoms = pow10Bigint(args.baseScalar, baseExp); const quoteAtoms = pow10Bigint( BigInt(mantissa.value) * args.baseScalar, - args.quoteExponentBiased, + quoteExp, ); // Re-bias: price_exponent = quote_exponent_biased + BIAS - base_exponent_biased - const rebiased = args.quoteExponentBiased + BIAS - args.baseExponentBiased; + const rebiased = quoteExp + BIAS - baseExp; if (rebiased < 0) throw new Error(PriceError.ExponentUnderflow); return { @@ -72,17 +79,17 @@ export function toOrderInfo(args: { * * Port of `OrderInfoArgs::order_at_price` in `price/src/lib.rs`. */ -export function orderAtPrice(priceMantissa: number): { - priceMantissa: number; +export function orderAtPrice(priceMantissa: number | bigint): { + priceMantissa: U32; baseScalar: bigint; - baseExponentBiased: number; - quoteExponentBiased: number; + baseExponentBiased: U8; + quoteExponentBiased: U8; } { return { - priceMantissa, + priceMantissa: validatePriceMantissa(priceMantissa).value, baseScalar: 1n, - baseExponentBiased: UNBIASED_MAX + BIAS, - quoteExponentBiased: BIAS - 1, + baseExponentBiased: ensureU8(UNBIASED_MAX + BIAS), + quoteExponentBiased: ensureU8(BIAS - 1), }; } diff --git a/ts-sdk/src/price/validated-mantissa.ts b/ts-sdk/src/price/validated-mantissa.ts index f3c9d2ef..25e966de 100644 --- a/ts-sdk/src/price/validated-mantissa.ts +++ b/ts-sdk/src/price/validated-mantissa.ts @@ -1,4 +1,5 @@ import { Decimal } from "decimal.js"; +import { ensureU32, type U32 } from "../rust-types"; import { PriceError } from "./error"; import { MANTISSA_DIGITS_LOWER_BOUND, @@ -12,22 +13,20 @@ import { */ export type ValidatedPriceMantissa = { readonly __brand: "ValidatedPriceMantissa"; - readonly value: number; + readonly value: U32; }; /** Port of `ValidatedPriceMantissa::try_from` in `price/src/validated_mantissa.rs`. */ export function validatePriceMantissa( - mantissa: number, + mantissa: number | bigint, ): ValidatedPriceMantissa { - if ( - mantissa < MANTISSA_DIGITS_LOWER_BOUND || - mantissa > MANTISSA_DIGITS_UPPER_BOUND - ) { + const v = ensureU32(mantissa); + if (v < MANTISSA_DIGITS_LOWER_BOUND || v > MANTISSA_DIGITS_UPPER_BOUND) { throw new Error(PriceError.InvalidPriceMantissa); } return { __brand: "ValidatedPriceMantissa", - value: mantissa, + value: v, } as ValidatedPriceMantissa; } 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..d56fb5d3 --- /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 { + const v = Number(n); + if (!Number.isInteger(v) || v < 0 || v > U16_MAX) { + throw new Error(RustTypeError.InvalidU16); + } + return 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..ee21b039 --- /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 { + const v = Number(n); + if (!Number.isInteger(v) || v < 0 || v > U32_MAX) { + throw new Error(RustTypeError.InvalidU32); + } + return 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..38f455c3 --- /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 { + 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..c8879759 --- /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 { + const v = Number(n); + if (!Number.isInteger(v) || v < 0 || v > U8_MAX) { + throw new Error(RustTypeError.InvalidU8); + } + return v as U8; +} 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); From ccd6b4dc9f88b5c39bbf17bc30b8afff8c584b1e Mon Sep 17 00:00:00 2001 From: Matt <90358481+xbtmatt@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:00:53 -0700 Subject: [PATCH 04/12] Split out `lint` command to have ts and ts:fix variants --- package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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", From 9039dced4d942347a20497d6f1e3a60faa34a677 Mon Sep 17 00:00:00 2001 From: Matt <90358481+xbtmatt@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:11:09 -0700 Subject: [PATCH 05/12] Use on the fly precise decimal --- ts-sdk/src/price/utils.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/ts-sdk/src/price/utils.ts b/ts-sdk/src/price/utils.ts index 7ebc967d..5fdedf5b 100644 --- a/ts-sdk/src/price/utils.ts +++ b/ts-sdk/src/price/utils.ts @@ -1,5 +1,16 @@ 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. * @@ -16,8 +27,9 @@ export function quoteFromBase( price: Decimal, ): bigint | Decimal { if (typeof baseAmount === "bigint") { + const D = preciseDecimal(baseAmount, price); return BigInt( - new Decimal(baseAmount.toString()) + new D(baseAmount.toString()) .times(price) .toDP(0, Decimal.ROUND_DOWN) .toFixed(), @@ -42,8 +54,9 @@ export function baseFromQuote( price: Decimal, ): bigint | Decimal { if (typeof quoteAmount === "bigint") { + const D = preciseDecimal(quoteAmount, price); return BigInt( - new Decimal(quoteAmount.toString()) + new D(quoteAmount.toString()) .div(price) .toDP(0, Decimal.ROUND_DOWN) .toFixed(), From bf9a12b96c85777636f0634b964dfa09c598c385 Mon Sep 17 00:00:00 2001 From: Matt <90358481+xbtmatt@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:29:32 -0700 Subject: [PATCH 06/12] Avoid downcasts by using bigint as intermediate type, avoid float errors --- ts-sdk/src/rust-types/u16.ts | 8 ++++---- ts-sdk/src/rust-types/u32.ts | 8 ++++---- ts-sdk/src/rust-types/u64.ts | 6 +++--- ts-sdk/src/rust-types/u8.ts | 8 ++++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/ts-sdk/src/rust-types/u16.ts b/ts-sdk/src/rust-types/u16.ts index d56fb5d3..162f36f0 100644 --- a/ts-sdk/src/rust-types/u16.ts +++ b/ts-sdk/src/rust-types/u16.ts @@ -9,9 +9,9 @@ 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 { - const v = Number(n); - if (!Number.isInteger(v) || v < 0 || v > U16_MAX) { + if (typeof n === "number" && !Number.isInteger(n)) throw new Error(RustTypeError.InvalidU16); - } - return v as U16; + 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 index ee21b039..6104eb32 100644 --- a/ts-sdk/src/rust-types/u32.ts +++ b/ts-sdk/src/rust-types/u32.ts @@ -9,9 +9,9 @@ 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 { - const v = Number(n); - if (!Number.isInteger(v) || v < 0 || v > U32_MAX) { + if (typeof n === "number" && !Number.isInteger(n)) throw new Error(RustTypeError.InvalidU32); - } - return v as U32; + 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 index 38f455c3..50d291f8 100644 --- a/ts-sdk/src/rust-types/u64.ts +++ b/ts-sdk/src/rust-types/u64.ts @@ -9,9 +9,9 @@ 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 { - const v = BigInt(n); - if (v < 0n || v > U64_MAX) { + if (typeof n === "number" && !Number.isInteger(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 index c8879759..89e2a112 100644 --- a/ts-sdk/src/rust-types/u8.ts +++ b/ts-sdk/src/rust-types/u8.ts @@ -9,9 +9,9 @@ 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 { - const v = Number(n); - if (!Number.isInteger(v) || v < 0 || v > U8_MAX) { + if (typeof n === "number" && !Number.isInteger(n)) throw new Error(RustTypeError.InvalidU8); - } - return v as U8; + const v = BigInt(n); + if (v < 0n || v > BigInt(U8_MAX)) throw new Error(RustTypeError.InvalidU8); + return Number(v) as U8; } From 63ae6124ed1cec23f6bf010dc8960ef4537364f4 Mon Sep 17 00:00:00 2001 From: Matt <90358481+xbtmatt@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:34:05 -0700 Subject: [PATCH 07/12] Add check for silent truncation on biased exponent conversion --- ts-sdk/src/price/encoded-price.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ts-sdk/src/price/encoded-price.ts b/ts-sdk/src/price/encoded-price.ts index a5dfd97b..034982b7 100644 --- a/ts-sdk/src/price/encoded-price.ts +++ b/ts-sdk/src/price/encoded-price.ts @@ -1,4 +1,5 @@ import { ensureU8, type U32, U32_MAX } from "../rust-types"; +import { PriceError } from "./error"; import { PRICE_MANTISSA_BITS } from "./lib"; import type { ValidatedPriceMantissa } from "./validated-mantissa"; @@ -20,8 +21,13 @@ export function encodePrice( biasedExponent: number | bigint, ): EncodedPrice { const exp = ensureU8(biasedExponent); - return (((exp << PRICE_MANTISSA_BITS) | mantissa.value) >>> - 0) as EncodedPrice; + const checkedBiasedExp = exp << PRICE_MANTISSA_BITS; + // This check will fail if the exponent is too large and truncates. + if (exp !== checkedBiasedExp) { + throw new Error(PriceError.InvalidBiasedExponent); + } + + return ((checkedBiasedExp | mantissa.value) >>> 0) as EncodedPrice; } export function isEncodedPriceInfinity(encoded: EncodedPrice): boolean { From fcc1845ffd50a2a5d684f570625aad1fc70e81ab Mon Sep 17 00:00:00 2001 From: Matt <90358481+xbtmatt@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:40:53 -0700 Subject: [PATCH 08/12] Fix checks and use `Number.isSafeInteger`, check rebiased overflow --- ts-sdk/src/price/client-helpers.ts | 7 ++++--- ts-sdk/src/price/encoded-price.ts | 9 +++------ ts-sdk/src/price/lib.ts | 1 + ts-sdk/src/rust-types/u16.ts | 2 +- ts-sdk/src/rust-types/u32.ts | 2 +- ts-sdk/src/rust-types/u64.ts | 2 +- ts-sdk/src/rust-types/u8.ts | 2 +- 7 files changed, 12 insertions(+), 13 deletions(-) diff --git a/ts-sdk/src/price/client-helpers.ts b/ts-sdk/src/price/client-helpers.ts index ab13889d..aaf28ea0 100644 --- a/ts-sdk/src/price/client-helpers.ts +++ b/ts-sdk/src/price/client-helpers.ts @@ -1,5 +1,5 @@ import { Decimal } from "decimal.js"; -import { ensureU8, type U8, type U32 } from "../rust-types"; +import { ensureU8, ensureU64, type U8, type U32, type U64 } from "../rust-types"; import { decodedPriceToDecimal, decodePrice } from "./decoded-price"; import { PriceError } from "./error"; import { @@ -77,13 +77,14 @@ export function toOrderInfoArgs( orderSizeBaseAtoms: bigint, ): { priceMantissa: U32; - baseScalar: bigint; + 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); @@ -91,7 +92,7 @@ export function toOrderInfoArgs( return { priceMantissa: mantissa.value, - baseScalar, + baseScalar: ensureU64(baseScalar), baseExponentBiased: toBiasedExponent(baseExponentUnbiased), quoteExponentBiased: toBiasedExponent(quoteExponentUnbiased), }; diff --git a/ts-sdk/src/price/encoded-price.ts b/ts-sdk/src/price/encoded-price.ts index 034982b7..9140ab84 100644 --- a/ts-sdk/src/price/encoded-price.ts +++ b/ts-sdk/src/price/encoded-price.ts @@ -1,6 +1,6 @@ import { ensureU8, type U32, U32_MAX } from "../rust-types"; import { PriceError } from "./error"; -import { PRICE_MANTISSA_BITS } from "./lib"; +import { PRICE_EXPONENT_MAX, PRICE_MANTISSA_BITS } from "./lib"; import type { ValidatedPriceMantissa } from "./validated-mantissa"; /** @@ -21,13 +21,10 @@ export function encodePrice( biasedExponent: number | bigint, ): EncodedPrice { const exp = ensureU8(biasedExponent); - const checkedBiasedExp = exp << PRICE_MANTISSA_BITS; - // This check will fail if the exponent is too large and truncates. - if (exp !== checkedBiasedExp) { + if (exp > PRICE_EXPONENT_MAX) { throw new Error(PriceError.InvalidBiasedExponent); } - - return ((checkedBiasedExp | mantissa.value) >>> 0) as EncodedPrice; + return (((exp << PRICE_MANTISSA_BITS) | mantissa.value) >>> 0) as EncodedPrice; } export function isEncodedPriceInfinity(encoded: EncodedPrice): boolean { diff --git a/ts-sdk/src/price/lib.ts b/ts-sdk/src/price/lib.ts index 484e0446..c41728bb 100644 --- a/ts-sdk/src/price/lib.ts +++ b/ts-sdk/src/price/lib.ts @@ -13,6 +13,7 @@ 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; diff --git a/ts-sdk/src/rust-types/u16.ts b/ts-sdk/src/rust-types/u16.ts index 162f36f0..b59d1547 100644 --- a/ts-sdk/src/rust-types/u16.ts +++ b/ts-sdk/src/rust-types/u16.ts @@ -9,7 +9,7 @@ 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.isInteger(n)) + 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); diff --git a/ts-sdk/src/rust-types/u32.ts b/ts-sdk/src/rust-types/u32.ts index 6104eb32..1129bb28 100644 --- a/ts-sdk/src/rust-types/u32.ts +++ b/ts-sdk/src/rust-types/u32.ts @@ -9,7 +9,7 @@ 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.isInteger(n)) + 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); diff --git a/ts-sdk/src/rust-types/u64.ts b/ts-sdk/src/rust-types/u64.ts index 50d291f8..96af1d66 100644 --- a/ts-sdk/src/rust-types/u64.ts +++ b/ts-sdk/src/rust-types/u64.ts @@ -9,7 +9,7 @@ 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.isInteger(n)) + 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); diff --git a/ts-sdk/src/rust-types/u8.ts b/ts-sdk/src/rust-types/u8.ts index 89e2a112..754b363e 100644 --- a/ts-sdk/src/rust-types/u8.ts +++ b/ts-sdk/src/rust-types/u8.ts @@ -9,7 +9,7 @@ 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.isInteger(n)) + 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); From eff74a6b5d04a7e0810655b919c74ad0a337668c Mon Sep 17 00:00:00 2001 From: Matt <90358481+xbtmatt@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:42:02 -0700 Subject: [PATCH 09/12] Use checked U64 arithmetic in to_order_info port --- ts-sdk/src/price/lib.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/ts-sdk/src/price/lib.ts b/ts-sdk/src/price/lib.ts index c41728bb..0595e838 100644 --- a/ts-sdk/src/price/lib.ts +++ b/ts-sdk/src/price/lib.ts @@ -1,4 +1,4 @@ -import { ensureU8, type U8, type U32 } from "../rust-types"; +import { ensureU8, ensureU64, type U8, type U32, type U64 } from "../rust-types"; import { type EncodedPrice, encodePrice } from "./encoded-price"; import { PriceError } from "./error"; import { @@ -50,17 +50,16 @@ export function toOrderInfo(args: { quoteExponentBiased: number | bigint; }): { encodedPrice: EncodedPrice; - baseAtoms: bigint; - quoteAtoms: bigint; + baseAtoms: U64; + quoteAtoms: U64; } { const mantissa = validatePriceMantissa(args.priceMantissa); const baseExp = ensureU8(args.baseExponentBiased); const quoteExp = ensureU8(args.quoteExponentBiased); - const baseAtoms = pow10Bigint(args.baseScalar, baseExp); - const quoteAtoms = pow10Bigint( - BigInt(mantissa.value) * args.baseScalar, - quoteExp, + 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 From bb2d58a1b772853ac0d269edf03e9133dd819d39 Mon Sep 17 00:00:00 2001 From: Matt <90358481+xbtmatt@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:42:11 -0700 Subject: [PATCH 10/12] Format --- ts-sdk/src/price/client-helpers.ts | 8 +++++++- ts-sdk/src/price/encoded-price.ts | 3 ++- ts-sdk/src/price/lib.ts | 8 +++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/ts-sdk/src/price/client-helpers.ts b/ts-sdk/src/price/client-helpers.ts index aaf28ea0..2cce7b58 100644 --- a/ts-sdk/src/price/client-helpers.ts +++ b/ts-sdk/src/price/client-helpers.ts @@ -1,5 +1,11 @@ import { Decimal } from "decimal.js"; -import { ensureU8, ensureU64, type U8, type U32, type U64 } from "../rust-types"; +import { + ensureU8, + ensureU64, + type U8, + type U32, + type U64, +} from "../rust-types"; import { decodedPriceToDecimal, decodePrice } from "./decoded-price"; import { PriceError } from "./error"; import { diff --git a/ts-sdk/src/price/encoded-price.ts b/ts-sdk/src/price/encoded-price.ts index 9140ab84..930d5233 100644 --- a/ts-sdk/src/price/encoded-price.ts +++ b/ts-sdk/src/price/encoded-price.ts @@ -24,7 +24,8 @@ export function encodePrice( if (exp > PRICE_EXPONENT_MAX) { throw new Error(PriceError.InvalidBiasedExponent); } - return (((exp << PRICE_MANTISSA_BITS) | mantissa.value) >>> 0) as EncodedPrice; + return (((exp << PRICE_MANTISSA_BITS) | mantissa.value) >>> + 0) as EncodedPrice; } export function isEncodedPriceInfinity(encoded: EncodedPrice): boolean { diff --git a/ts-sdk/src/price/lib.ts b/ts-sdk/src/price/lib.ts index 0595e838..c9975eb8 100644 --- a/ts-sdk/src/price/lib.ts +++ b/ts-sdk/src/price/lib.ts @@ -1,4 +1,10 @@ -import { ensureU8, ensureU64, type U8, type U32, type U64 } from "../rust-types"; +import { + ensureU8, + ensureU64, + type U8, + type U32, + type U64, +} from "../rust-types"; import { type EncodedPrice, encodePrice } from "./encoded-price"; import { PriceError } from "./error"; import { From 21229a65a85ba47a36c19a8072afffa389787d2a Mon Sep 17 00:00:00 2001 From: Matt <90358481+xbtmatt@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:39:13 -0700 Subject: [PATCH 11/12] Don't use hardcoded magic numbers, use PRICE_EXPONENT_MAX --- ts-sdk/src/price/lib.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts-sdk/src/price/lib.ts b/ts-sdk/src/price/lib.ts index c9975eb8..77b9a42d 100644 --- a/ts-sdk/src/price/lib.ts +++ b/ts-sdk/src/price/lib.ts @@ -33,7 +33,7 @@ export function pow10Bigint( const exp = ensureU8(biasedExponent); if (exp === BIAS) return value; - if (exp > 31) { + if (exp > PRICE_EXPONENT_MAX) { throw new Error(PriceError.InvalidBiasedExponent); } From 462425be744a794d64015575b8322d86a0e65ab0 Mon Sep 17 00:00:00 2001 From: Matt <90358481+xbtmatt@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:54:48 -0700 Subject: [PATCH 12/12] Early return with better error msg in validated price mantissa check --- ts-sdk/src/price/validated-mantissa.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts-sdk/src/price/validated-mantissa.ts b/ts-sdk/src/price/validated-mantissa.ts index 25e966de..51eece19 100644 --- a/ts-sdk/src/price/validated-mantissa.ts +++ b/ts-sdk/src/price/validated-mantissa.ts @@ -40,7 +40,7 @@ export function normalizePriceMantissa(price: Decimal): { mantissa: ValidatedPriceMantissa; scale: number; } { - if (price.lte(0)) { + if (price.lte(0) || !price.isFinite()) { throw new Error(PriceError.InvalidPriceMantissa); }