diff --git a/common-ts/docs/superpowers/plans/2026-03-26-utils-restructure.md b/common-ts/docs/superpowers/plans/2026-03-26-utils-restructure.md new file mode 100644 index 00000000..8984aa3e --- /dev/null +++ b/common-ts/docs/superpowers/plans/2026-03-26-utils-restructure.md @@ -0,0 +1,756 @@ +# Utils Restructure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reorganize `@drift-labs/common` utility methods into domain-based modules under `src/utils/` with subpath exports and deprecation facades for backwards compatibility. + +**Architecture:** Functions currently scattered across `src/utils/index.ts` (865-line god file), `src/common-ui-utils/` (5 files), and standalone util files get moved into 11 domain-based modules under `src/utils/`. Old namespace objects (`COMMON_UTILS`, `COMMON_UI_UTILS`, etc.) are preserved as deprecation facades in `src/_deprecated/`. The main `src/index.ts` barrel re-exports everything. + +**Tech Stack:** TypeScript 5.4, tsc (no bundler), CommonJS output, `@drift-labs/sdk`, `@solana/web3.js` + +**Spec:** `docs/superpowers/specs/2026-03-26-utils-restructure-design.md` + +--- + +## File Structure + +### New files to create + +**Domain modules (each with `index.ts` barrel):** +``` +src/utils/math/index.ts, numbers.ts, bn.ts, bignum.ts, precision.ts, spread.ts, price.ts, sort.ts +src/utils/strings/index.ts, format.ts, parse.ts, convert.ts, status.ts +src/utils/enum/index.ts (copy of existing src/utils/enum.ts) +src/utils/validation/index.ts (barrel re-exporting existing validation.ts content) +src/utils/token/index.ts, address.ts, account.ts, instructions.ts +src/utils/trading/index.ts, auction.ts, pnl.ts, liquidation.ts, leverage.ts, lp.ts, price.ts, size.ts +src/utils/markets/index.ts, config.ts, leverage.ts, operations.ts, interest.ts, balances.ts +src/utils/orders/index.ts, labels.ts, filters.ts, sort.ts, oracle.ts, flags.ts, misc.ts +src/utils/positions/index.ts, open.ts, user.ts +src/utils/accounts/index.ts, init.ts, keys.ts, subaccounts.ts, wallet.ts, signature.ts, multiple.ts +src/utils/core/index.ts, async.ts, arrays.ts, data-structures.ts, equality.ts, cache.ts, fetch.ts, serialization.ts +``` + +**Deprecation facades:** +``` +src/_deprecated/utils.ts +src/_deprecated/common-ui-utils.ts +src/_deprecated/common-math.ts +src/_deprecated/equality-checks.ts +src/_deprecated/trading-utils.ts +src/_deprecated/market-utils.ts +src/_deprecated/order-utils.ts +src/_deprecated/user-utils.ts +``` + +### Files to modify +``` +src/index.ts (complete rewrite of barrel) +package.json (add exports, typesVersions) +``` + +### Files to delete (after migration) +``` +src/utils/index.ts (replaced by new utils/index.ts barrel + domain modules) +src/common-ui-utils/ (entire directory — moved to domain modules + utils/settings/) +src/utils/equalityChecks.ts (moved to core/equality.ts) +src/utils/fetch.ts (moved to core/fetch.ts) +src/utils/WalletConnectionState.ts (moved to accounts/wallet.ts) +src/utils/enum.ts (replaced by utils/enum/index.ts) +src/utils/strings.ts (replaced by utils/strings/) +src/utils/validation.ts (replaced by utils/validation/) +src/utils/token.ts (replaced by utils/token/) +src/utils/math.ts (replaced by utils/math/) +``` + +### Files to move (not delete, just relocate) +``` +src/common-ui-utils/settings/settings.ts → src/utils/settings/settings.ts (copy before deleting common-ui-utils/) +``` + +### Files to create (new barrel to replace deleted god file) +``` +src/utils/index.ts (new barrel that re-exports all domain modules) +``` + +### Files to keep unchanged +``` +src/utils/logger.ts, featureFlags.ts, geoblock/, priority-fees/, +candles/, orderbook/, CircularBuffers/, dlob-server/, rxjs.ts, rpcLatency.ts, +driftEvents.ts, pollingSequenceGuard.ts, priorityFees.ts, NumLib.ts, millify.ts, +MultiplexWebSocket.ts, SharedInterval.ts, Stopwatch.ts, SlotBasedResultValidator.ts, +ResultSlotIncrementer.ts, signedMsgs.ts, s3Buckets.ts, superstake.ts, +priceImpact.ts, assert.ts, insuranceFund.ts, StrictEventEmitter.ts +``` + +### Files that stay but need internal import updates +``` +src/utils/superstake.ts (imports aprFromApy from '../utils' → '../utils/math/numbers') +src/drift/ files (imports of sleep, ENUM_UTILS from '../utils' → new paths) +``` + +--- + +## Task 1: Create `utils/core/` module + +Extract universal, non-domain utilities from `utils/index.ts` and `common-ui-utils/commonUiUtils.ts`. + +**Files:** +- Create: `src/utils/core/async.ts` +- Create: `src/utils/core/arrays.ts` +- Create: `src/utils/core/data-structures.ts` +- Create: `src/utils/core/cache.ts` +- Create: `src/utils/core/fetch.ts` +- Create: `src/utils/core/serialization.ts` +- Create: `src/utils/core/equality.ts` +- Create: `src/utils/core/index.ts` + +**Source mapping:** +- `async.ts`: `sleep` (utils/index.ts:35), `timedPromise` (utils/index.ts:803) +- `arrays.ts`: `chunks` (utils/index.ts:813 — canonical), `glueArray` (utils/index.ts:748) +- `data-structures.ts`: `Ref` (utils/index.ts:391), `Counter` (utils/index.ts:407), `MultiSwitch` (utils/index.ts:429) +- `cache.ts`: `uiStringCache`, `MAX_UI_STRING_CACHE_SIZE`, `getCachedUiString` (common-ui-utils/commonUiUtils.ts:43-86) +- `fetch.ts`: `encodeQueryParams` (utils/fetch.ts:1-11) +- `serialization.ts`: `getStringifiableObjectEntry`, `encodeStringifiableObject`, `decodeStringifiableObject` (utils/index.ts:38-136) +- `equality.ts`: entire `utils/equalityChecks.ts` file (types + `arePropertiesEqual`, `areTwoOpenPositionsEqual`, `areOpenPositionListsEqual`, `EQUALITY_CHECKS`) + +- [ ] **Step 1: Create `src/utils/core/async.ts`** + + Copy `sleep` and `timedPromise` from `src/utils/index.ts`. Export both. + +- [ ] **Step 2: Create `src/utils/core/arrays.ts`** + + Copy `chunks` (canonical implementation from utils/index.ts:813-818) and `glueArray` from `src/utils/index.ts`. Export both. + +- [ ] **Step 3: Create `src/utils/core/data-structures.ts`** + + Copy `Ref`, `Counter`, `MultiSwitch` classes from `src/utils/index.ts`. Export all. + +- [ ] **Step 4: Create `src/utils/core/cache.ts`** + + Copy `uiStringCache`, `MAX_UI_STRING_CACHE_SIZE`, `getCachedUiString` from `src/common-ui-utils/commonUiUtils.ts:43-86`. Export `getCachedUiString` (the cache itself is module-private state). + +- [ ] **Step 5: Create `src/utils/core/fetch.ts`** + + Copy `encodeQueryParams` from `src/utils/fetch.ts`. + +- [ ] **Step 6: Create `src/utils/core/serialization.ts`** + + Copy `getStringifiableObjectEntry` (private), `encodeStringifiableObject`, `decodeStringifiableObject` from `src/utils/index.ts:38-136`. + +- [ ] **Step 7: Create `src/utils/core/equality.ts`** + + Copy entire content of `src/utils/equalityChecks.ts`. Update import of `ENUM_UTILS` to import from `../enum` (which will be created in Task 3). + +- [ ] **Step 8: Create `src/utils/core/index.ts`** + + Barrel file re-exporting everything from all sub-modules. + +- [ ] **Step 9: Verify types compile** + + Run: `npx tsc --noEmit --project tsconfig.json 2>&1 | head -30` + Note: This will have errors since we haven't wired up the barrel yet — that's expected. We're just checking the individual files compile in isolation. + +- [ ] **Step 10: Commit** + + ```bash + git add src/utils/core/ + git commit -m "refactor(common-ts): create utils/core module with universal utilities" + ``` + +--- + +## Task 2: Create `utils/math/` module + +Extract math utilities from `src/utils/math.ts` and `src/utils/index.ts`. + +**Files:** +- Create: `src/utils/math/numbers.ts` +- Create: `src/utils/math/bn.ts` +- Create: `src/utils/math/bignum.ts` +- Create: `src/utils/math/precision.ts` +- Create: `src/utils/math/spread.ts` +- Create: `src/utils/math/price.ts` +- Create: `src/utils/math/sort.ts` +- Create: `src/utils/math/index.ts` + +**Source mapping:** +- `numbers.ts`: `calculateMean` (utils/index.ts:720), `calculateMedian` (utils/index.ts:725), `calculateStandardDeviation` (utils/index.ts:735), `calculateZScore` (utils/index.ts:709), `getPctCompletion` (utils/math.ts:115), `roundToDecimal` (utils/math.ts:205), `aprFromApy` (utils/index.ts:669) +- `bn.ts`: `bnMin`, `bnMax`, `bnMean`, `bnMedian` (utils/index.ts:763-801), `sortBnAsc`, `sortBnDesc` (utils/math.ts:126-134) +- `bignum.ts`: `roundBigNumToDecimalPlace` (utils/math.ts:212), `getBigNumRoundedToStepSize` (utils/math.ts:136) +- `precision.ts`: `TRADE_PRECISION` (utils/math.ts:113), `roundToStepSize`, `roundToStepSizeIfLargeEnough`, `truncateInputToPrecision`, `valueIsBelowStepSize`, `numbersFitEvenly` (utils/math.ts:141-203), `dividesExactly` (utils/index.ts:645) +- `spread.ts`: `calculateMarkPrice`, `calculateBidAskAndmarkPrice`, `calculateSpreadQuote` (private), `calculateSpreadPct` (private), `calculateSpread`, `calculateSpreadBidAskMark` (utils/math.ts:10-111) +- `price.ts`: `getPriceForBaseAndQuoteAmount`, `getPriceForOrderRecord`, `getPriceForUIOrderRecord` (utils/index.ts:343-379), `calculateAverageEntryPrice` (common-ui-utils/commonUiUtils.ts:342-356) +- `sort.ts`: `getTieredSortScore` (utils/index.ts:686), `sortRecordsByTs` (utils/math.ts:221) + +- [ ] **Step 1: Create all files in `src/utils/math/`** + + Extract functions per the source mapping above. Each file should import what it needs from `@drift-labs/sdk`. + +- [ ] **Step 2: Create `src/utils/math/index.ts` barrel** + + Re-export everything from all sub-modules. + +- [ ] **Step 3: Commit** + + ```bash + git add src/utils/math/ + git commit -m "refactor(common-ts): create utils/math module" + ``` + +--- + +## Task 3: Create `utils/enum/` module + +**Files:** +- Create: `src/utils/enum/index.ts` + +**Source:** Copy content from `src/utils/enum.ts` into `src/utils/enum/index.ts`. + +- [ ] **Step 1: Create `src/utils/enum/index.ts`** + + Copy content from `src/utils/enum.ts`. No changes needed to the code itself. + +- [ ] **Step 2: Commit** + + ```bash + git add src/utils/enum/ + git commit -m "refactor(common-ts): create utils/enum module" + ``` + +--- + +## Task 4: Create `utils/strings/` module + +**Files:** +- Create: `src/utils/strings/format.ts` +- Create: `src/utils/strings/parse.ts` +- Create: `src/utils/strings/convert.ts` +- Create: `src/utils/strings/status.ts` +- Create: `src/utils/strings/index.ts` + +**Source mapping:** +- `format.ts`: `abbreviateAddress` (common-ui-utils/commonUiUtils.ts:92 — uses `getCachedUiString` from `../core/cache`), `abbreviateAccountName` (utils/strings.ts:6), `trimTrailingZeros` (common-ui-utils/commonUiUtils.ts:954), `toSnakeCase` (utils/index.ts:657), `toCamelCase` (utils/index.ts:660), `normalizeBaseAssetSymbol` (utils/index.ts:699) +- `parse.ts`: `splitByCapitalLetters`, `lowerCaseNonFirstWords`, `disallowNegativeStringInput` (utils/strings.ts:25-42), `isValidBase58` (utils/strings.ts:3) +- `convert.ts`: `toPrintableObject`, `convertStringValuesToNumbers`, `extractStringValuesFromObject` (utils/strings.ts:69-152) +- `status.ts`: `LAST_ORDER_STATUS_LABELS` (note: not currently exported in source — add `export` keyword), `LastOrderStatus`, `LastOrderStatusLabel`, `lastOrderStatusToNormalEng` (utils/strings.ts:47-64) + +- [ ] **Step 1: Create all files in `src/utils/strings/`** + + In `format.ts`, import `getCachedUiString` from `../core/cache`. The `abbreviateAddress` function depends on it. + +- [ ] **Step 2: Create `src/utils/strings/index.ts` barrel** + +- [ ] **Step 3: Commit** + + ```bash + git add src/utils/strings/ + git commit -m "refactor(common-ts): create utils/strings module" + ``` + +--- + +## Task 5: Create `utils/validation/` module + +**Files:** +- Create: `src/utils/validation/index.ts` +- Create: `src/utils/validation/address.ts` +- Create: `src/utils/validation/notional.ts` +- Create: `src/utils/validation/input.ts` + +**Source mapping:** +- `address.ts`: all content from `src/utils/validation.ts` (re-exports `isValidBase58` from `../strings/parse`) +- `notional.ts`: `isNotionalDust` from `src/utils/validation.ts:3` +- `input.ts`: `formatTokenInputCurried` from `src/common-ui-utils/commonUiUtils.ts:974` + +- [ ] **Step 1: Create all files in `src/utils/validation/`** + + Split current `validation.ts` content: address-related goes to `address.ts`, `isNotionalDust` goes to `notional.ts`, `formatTokenInputCurried` comes from `common-ui-utils/commonUiUtils.ts`. + +- [ ] **Step 2: Create `src/utils/validation/index.ts` barrel** + +- [ ] **Step 3: Commit** + + ```bash + git add src/utils/validation/ + git commit -m "refactor(common-ts): create utils/validation module" + ``` + +--- + +## Task 6: Create `utils/token/` module + +**Files:** +- Create: `src/utils/token/address.ts` +- Create: `src/utils/token/account.ts` +- Create: `src/utils/token/instructions.ts` +- Create: `src/utils/token/index.ts` + +**Source mapping:** +- `address.ts`: `getTokenAddress` from both `utils/token.ts:17` (string params) and `common-ui-utils/commonUiUtils.ts:808` (PublicKey params) — keep both, export with overloads or separate names (`getTokenAddress` for PublicKey, `getTokenAddressFromStrings` or simply both). Also `getTokenAddressForDepositAndWithdraw` from `utils/token.ts:36`. +- `account.ts`: `getTokenAccount` from `common-ui-utils/commonUiUtils.ts:822` (richer version with warning), `getBalanceFromTokenAccountResult` from `common-ui-utils/commonUiUtils.ts:815`. Also keep the string-based version from `utils/token.ts:52` as a separate function. +- `instructions.ts`: `createTokenAccountIx` from `utils/token.ts:81`, re-exports of `TOKEN_PROGRAM_ID` and `createTransferCheckedInstruction` from `utils/token.ts:12-15`. + +- [ ] **Step 1: Create all files in `src/utils/token/`** + + For deduplication: keep both `getTokenAddress` signatures (string params and PublicKey params) in `address.ts`. Name the PublicKey version `getTokenAddress` and add a `getTokenAddressFromStrings` wrapper for the string version, or keep both with different parameter overloads. + + Similarly for `getTokenAccount`: keep the richer version (with warning detection) from `commonUiUtils.ts` as the primary, and keep the simpler string-based version from `token.ts` alongside it. + +- [ ] **Step 2: Create `src/utils/token/index.ts` barrel** + +- [ ] **Step 3: Commit** + + ```bash + git add src/utils/token/ + git commit -m "refactor(common-ts): create utils/token module with deduplicated functions" + ``` + +--- + +## Task 7: Create `utils/trading/` module + +**Files:** +- Create: `src/utils/trading/auction.ts` +- Create: `src/utils/trading/pnl.ts` +- Create: `src/utils/trading/liquidation.ts` +- Create: `src/utils/trading/leverage.ts` +- Create: `src/utils/trading/lp.ts` +- Create: `src/utils/trading/price.ts` +- Create: `src/utils/trading/size.ts` +- Create: `src/utils/trading/index.ts` + +**Source mapping:** +- `auction.ts`: `getMarketAuctionParams`, `getLimitAuctionParams`, `deriveMarketOrderParams`, `getPriceObject` (common-ui-utils/commonUiUtils.ts:395-772) +- `pnl.ts`: `calculatePnlPctFromPosition`, `calculatePotentialProfit`, `POTENTIAL_PROFIT_DEFAULT_STATE` (common-ui-utils/trading.ts:21-149) +- `liquidation.ts`: `calculateLiquidationPriceAfterPerpTrade` (common-ui-utils/trading.ts:162-275) +- `leverage.ts`: `convertLeverageToMarginRatio`, `convertMarginRatioToLeverage`, `getMarginUsedForPosition`, `validateLeverageChange` (common-ui-utils/trading.ts:277-486) +- `lp.ts`: `getLpSharesAmountForQuote`, `getQuoteValueForLpShares` (common-ui-utils/commonUiUtils.ts:775-806) +- `price.ts`: `getMarketOrderLimitPrice`, `checkIsMarketOrderType` (common-ui-utils/commonUiUtils.ts:358-393, common-ui-utils/trading.ts:154-156) +- `size.ts`: `getMarketTickSize`, `getMarketTickSizeDecimals`, `getMarketStepSize`, `getMarketStepSizeDecimals`, `isEntirePositionOrder`, `getMaxLeverageOrderSize`, `formatOrderSize` (common-ui-utils/trading.ts:295-409) + +Note: `auction.ts` imports `getMarketOrderLimitPrice` from `./price` and `getPriceObject` is used by `deriveMarketOrderParams`, so they must be in the same file or `auction.ts` imports from `./price`. + +- [ ] **Step 1: Create all files in `src/utils/trading/`** + + Key internal dependency: `deriveMarketOrderParams` calls `getMarketOrderLimitPrice` and `getPriceObject`. Put `getPriceObject` in `auction.ts` alongside `deriveMarketOrderParams` since it's only used there. Import `getMarketOrderLimitPrice` from `./price`. + + `calculatePnlPctFromPosition` in `pnl.ts` calls `convertMarginRatioToLeverage` from `./leverage`. + +- [ ] **Step 2: Create `src/utils/trading/index.ts` barrel** + +- [ ] **Step 3: Commit** + + ```bash + git add src/utils/trading/ + git commit -m "refactor(common-ts): create utils/trading module" + ``` + +--- + +## Task 8: Create `utils/markets/` module + +**Files:** +- Create: `src/utils/markets/config.ts` +- Create: `src/utils/markets/leverage.ts` +- Create: `src/utils/markets/operations.ts` +- Create: `src/utils/markets/interest.ts` +- Create: `src/utils/markets/balances.ts` +- Create: `src/utils/markets/index.ts` + +**Source mapping:** +- `config.ts`: `getMarketConfig`, `getBaseAssetSymbol` (common-ui-utils/market.ts:19-123) +- `leverage.ts`: `getMaxLeverageForMarket`, `getMaxLeverageForMarketAccount` (common-ui-utils/market.ts:125-206) +- `operations.ts`: `getPausedOperations`, `PerpOperationsMap`, `SpotOperationsMap`, `InsuranceFundOperationsMap` (common-ui-utils/market.ts:29-99) +- `interest.ts`: `getCurrentOpenInterestForMarket`, `getDepositAprForMarket`, `getBorrowAprForMarket` (utils/index.ts:493-574) +- `balances.ts`: `getTotalBorrowsForMarket`, `getTotalDepositsForMarket` (utils/index.ts:576-637) + +Note: `src/utils/markets/precisions.ts` already exists and stays unchanged. + +- [ ] **Step 1: Create all files in `src/utils/markets/`** + +- [ ] **Step 2: Create `src/utils/markets/index.ts` barrel** + + Re-export from all sub-modules AND the existing `./precisions` file. + +- [ ] **Step 3: Commit** + + ```bash + git add src/utils/markets/ + git commit -m "refactor(common-ts): create utils/markets module" + ``` + +--- + +## Task 9: Create `utils/orders/` module + +**Files:** +- Create: `src/utils/orders/labels.ts` +- Create: `src/utils/orders/filters.ts` +- Create: `src/utils/orders/sort.ts` +- Create: `src/utils/orders/oracle.ts` +- Create: `src/utils/orders/flags.ts` +- Create: `src/utils/orders/misc.ts` +- Create: `src/utils/orders/index.ts` + +**Source mapping:** +- `labels.ts`: `getOrderLabelFromOrderDetails`, `getUIOrderTypeFromSdkOrderType` (common-ui-utils/order.ts:34-214) +- `filters.ts`: `orderActionRecordIsTrade`, `uiOrderActionRecordIsTrade`, `filterTradeRecordsFromOrderActionRecords`, `filterTradeRecordsFromUIOrderRecords`, `isOrderTriggered` (utils/index.ts:313-335, common-ui-utils/order.ts:291-314) +- `sort.ts`: `getSortScoreForOrderRecords`, `getSortScoreForOrderActionRecords`, `sortUIMatchedOrderRecordAndAction`, `sortUIOrderActionRecords`, `sortUIOrderRecords`, `sortOrderRecords`, `getLatestOfTwoUIOrderRecords`, `getLatestOfTwoOrderRecords`, `getUIOrderRecordsLaterThanTarget`, `getChronologicalValueForOrderAction` (private) (utils/index.ts:138-310). Also types: `PartialOrderActionRecord`, `PartialUISerializableOrderActionRecord`, `PartialOrderActionEventRecord`. +- `oracle.ts`: `getLimitPriceFromOracleOffset`, `isAuctionEmpty` (common-ui-utils/order.ts:130-153) +- `flags.ts`: `getPerpOrderParamsBitFlags`, `getPerpAuctionDuration`, `HighLeverageOptions` type (common-ui-utils/order.ts:217-289) +- `misc.ts`: `orderIsNull`, `getTradeInfoFromActionRecord`, `getAnchorEnumString` (utils/index.ts:163-390) + +- [ ] **Step 1: Create all files in `src/utils/orders/`** + + Key: `sort.ts` needs types `PartialOrderActionRecord`, `PartialUISerializableOrderActionRecord`, `PartialOrderActionEventRecord` moved alongside it. + +- [ ] **Step 2: Create `src/utils/orders/index.ts` barrel** + +- [ ] **Step 3: Commit** + + ```bash + git add src/utils/orders/ + git commit -m "refactor(common-ts): create utils/orders module" + ``` + +--- + +## Task 10: Create `utils/positions/` module + +**Files:** +- Create: `src/utils/positions/open.ts` +- Create: `src/utils/positions/user.ts` +- Create: `src/utils/positions/index.ts` + +**Source mapping:** +- `open.ts`: `getOpenPositionData` (common-ui-utils/user.ts:32-206) +- `user.ts`: `checkIfUserAccountExists`, `getUserMaxLeverageForMarket` (common-ui-utils/user.ts:208-313) + +Note: `getOpenPositionData` calls `TRADING_UTILS.calculatePotentialProfit`. Update to import directly: `import { calculatePotentialProfit } from '../trading/pnl'`. + +- [ ] **Step 1: Create all files in `src/utils/positions/`** + +- [ ] **Step 2: Create `src/utils/positions/index.ts` barrel** + +- [ ] **Step 3: Commit** + + ```bash + git add src/utils/positions/ + git commit -m "refactor(common-ts): create utils/positions module" + ``` + +--- + +## Task 11: Create `utils/accounts/` module + +**Files:** +- Create: `src/utils/accounts/init.ts` +- Create: `src/utils/accounts/keys.ts` +- Create: `src/utils/accounts/subaccounts.ts` +- Create: `src/utils/accounts/wallet.ts` +- Create: `src/utils/accounts/signature.ts` +- Create: `src/utils/accounts/multiple.ts` +- Create: `src/utils/accounts/index.ts` + +**Source mapping:** +- `init.ts`: `initializeAndSubscribeToNewUserAccount`, `awaitAccountInitializationChainState`, `updateUserAccount` (private), `ACCOUNT_INITIALIZATION_RETRY_DELAY_MS`, `ACCOUNT_INITIALIZATION_RETRY_ATTEMPTS` (common-ui-utils/commonUiUtils.ts:88-271). Imports `sleep` from `../core/async`. +- `keys.ts`: `getUserKey`, `getIdAndAuthorityFromKey`, `getMarketKey` (common-ui-utils/commonUiUtils.ts:104-274). Imports `getCachedUiString` from `../core/cache`. +- `subaccounts.ts`: `fetchCurrentSubaccounts`, `fetchUserClientsAndAccounts`, `userExists` (common-ui-utils/commonUiUtils.ts:130-941) +- `wallet.ts`: all content from `src/utils/WalletConnectionState.ts` + `createPlaceholderIWallet` (common-ui-utils/commonUiUtils.ts:280-305) +- `signature.ts`: `getSignatureVerificationMessageForSettings`, `verifySignature`, `hashSignature`, `compareSignatures` (common-ui-utils/commonUiUtils.ts:307-338) +- `multiple.ts`: `getMultipleAccounts`, `getMultipleAccountsCore`, `getMultipleAccountsInfoChunked` (common-ui-utils/commonUiUtils.ts:870-924, utils/index.ts:820-829). Uses `chunks` from `../core/arrays`. + +- [ ] **Step 1: Create all files in `src/utils/accounts/`** + +- [ ] **Step 2: Move `src/common-ui-utils/settings/settings.ts` to `src/utils/settings/settings.ts`** + + Copy the file to `src/utils/settings/settings.ts`. This must happen before Task 13 deletes `src/common-ui-utils/`. The file has no imports from `common-ui-utils/` so no import path changes are needed. + + ```bash + mkdir -p src/utils/settings + cp src/common-ui-utils/settings/settings.ts src/utils/settings/settings.ts + ``` + +- [ ] **Step 3: Create `src/utils/accounts/index.ts` barrel** + +- [ ] **Step 4: Commit** + + ```bash + git add src/utils/accounts/ src/utils/settings/ + git commit -m "refactor(common-ts): create utils/accounts module, move settings to utils/" + ``` + +--- + +## Task 12: Create deprecation facades in `src/_deprecated/` + +**Files:** +- Create: `src/_deprecated/utils.ts` +- Create: `src/_deprecated/common-ui-utils.ts` +- Create: `src/_deprecated/common-math.ts` +- Create: `src/_deprecated/equality-checks.ts` +- Create: `src/_deprecated/trading-utils.ts` +- Create: `src/_deprecated/market-utils.ts` +- Create: `src/_deprecated/order-utils.ts` +- Create: `src/_deprecated/user-utils.ts` + +Each file reconstructs the original namespace object by importing from new module locations. + +- [ ] **Step 1: Create `src/_deprecated/utils.ts`** + + Reconstruct `COMMON_UTILS` exactly matching the shape from `src/utils/index.ts:831-861`. Import each function from its new canonical location. + + ```typescript + import { getIfVaultBalance, getIfStakingVaultApr } from '../utils/insuranceFund'; + import { getCurrentOpenInterestForMarket, getDepositAprForMarket, getBorrowAprForMarket } from '../utils/markets/interest'; + import { getTotalBorrowsForMarket, getTotalDepositsForMarket } from '../utils/markets/balances'; + import { dividesExactly } from '../utils/math/precision'; + import { toSnakeCase, toCamelCase, normalizeBaseAssetSymbol } from '../utils/strings/format'; + import { getTieredSortScore } from '../utils/math/sort'; + import { calculateZScore, calculateMean, calculateMedian } from '../utils/math/numbers'; + import { glueArray, chunks } from '../utils/core/arrays'; + import { timedPromise } from '../utils/core/async'; + import { bnMax, bnMin, bnMean, bnMedian } from '../utils/math/bn'; + import { getMultipleAccountsInfoChunked } from '../utils/accounts/multiple'; + + /** @deprecated Use direct imports from '@drift-labs/common/utils/math' etc. */ + export const COMMON_UTILS = { + getIfVaultBalance, getIfStakingVaultApr, + getCurrentOpenInterestForMarket, getDepositAprForMarket, getBorrowAprForMarket, + getTotalBorrowsForMarket, getTotalDepositsForMarket, + dividesExactly, toSnakeCase, toCamelCase, getTieredSortScore, normalizeBaseAssetSymbol, + calculateZScore, glueArray, timedPromise, chunks, getMultipleAccountsInfoChunked, + MATH: { + NUM: { mean: calculateMean, median: calculateMedian }, + BN: { bnMax, bnMin, bnMean, bnMedian }, + }, + }; + ``` + +- [ ] **Step 2: Create `src/_deprecated/trading-utils.ts`** + + Reconstruct `TRADING_UTILS` matching shape from `src/common-ui-utils/trading.ts:488-504`. + +- [ ] **Step 3: Create `src/_deprecated/market-utils.ts`** + + Reconstruct `MARKET_UTILS` matching shape from `src/common-ui-utils/market.ts:208-217`. + +- [ ] **Step 4: Create `src/_deprecated/order-utils.ts`** + + Reconstruct `ORDER_COMMON_UTILS` matching shape from `src/common-ui-utils/order.ts:316-324`. + +- [ ] **Step 5: Create `src/_deprecated/user-utils.ts`** + + Reconstruct `USER_UTILS` matching shape from `src/common-ui-utils/user.ts:315-319`. + +- [ ] **Step 6: Create `src/_deprecated/common-ui-utils.ts`** + + Reconstruct `COMMON_UI_UTILS` matching shape from `src/common-ui-utils/commonUiUtils.ts:1011-1045`. Import individual functions from new modules and spread in the sub-namespace utils. + +- [ ] **Step 7: Create `src/_deprecated/common-math.ts`** + + Reconstruct `COMMON_MATH` matching shape from `src/utils/math.ts:232-234`: + ```typescript + import { calculateSpreadBidAskMark } from '../utils/math/spread'; + /** @deprecated Use direct import from '@drift-labs/common/utils/math' */ + export const COMMON_MATH = { calculateSpreadBidAskMark }; + ``` + +- [ ] **Step 8: Create `src/_deprecated/equality-checks.ts`** + + Re-export `EQUALITY_CHECKS` from `../utils/core/equality`. + +- [ ] **Step 9: Commit** + + ```bash + git add src/_deprecated/ + git commit -m "refactor(common-ts): create deprecation facades for backwards compatibility" + ``` + +--- + +## Task 13: Create new `utils/index.ts` barrel, rewrite `src/index.ts`, fix all internal imports, delete old files + +**Files:** +- Create: `src/utils/index.ts` (new barrel re-exporting all domain modules) +- Modify: `src/index.ts` (complete rewrite) +- Modify: `src/utils/superstake.ts` (import update: `aprFromApy`) +- Modify: any `src/drift/` files importing from `../utils` or `../common-ui-utils` +- Delete: old source files (see list below) + +- [ ] **Step 1: Create new `src/utils/index.ts` barrel** + + This barrel re-exports all domain modules so that existing `from '../utils'` imports still resolve. This is critical for minimizing import path breakage. + + ```typescript + export * from './math'; + export * from './strings'; + export * from './enum'; + export * from './validation'; + export * from './token'; + export * from './trading'; + export * from './markets'; + export * from './orders'; + export * from './positions'; + export * from './accounts'; + export * from './core'; + ``` + +- [ ] **Step 2: Rewrite `src/index.ts`** + + Replace the entire barrel with the new structure from the spec. See `src/index.ts (Main Barrel)` section in the design spec. Include `export * from './utils/insuranceFund'` so `getIfVaultBalance` and `getIfStakingVaultApr` remain available as direct named exports. + +- [ ] **Step 3: Fix ALL internal imports before deleting old files** + + Search the entire `src/` tree for imports from old paths and update them. Key files: + + **Within `src/utils/` itself:** + - `src/utils/superstake.ts`: change `import { aprFromApy } from '../utils'` → `import { aprFromApy } from './math/numbers'` + - Any other `src/utils/*.ts` file importing from `'.'` or `'./index'` or `'../utils'` + + **Within `src/drift/`:** + - `src/drift/cli.ts` (or similar): `ENUM_UTILS` from `'../utils'` → `'../utils/enum'` + - Any file importing `sleep` from `'../utils'` → `'../utils/core/async'` (or just `'../utils/core'`) + + **Search commands:** + ```bash + grep -rn "from '\.\./utils'" src/ --include="*.ts" | grep -v node_modules + grep -rn "from '\.\./common-ui-utils'" src/ --include="*.ts" | grep -v node_modules + grep -rn "from '\./commonUiUtils'" src/ --include="*.ts" | grep -v node_modules + grep -rn "from '\.'" src/utils/ --include="*.ts" | grep -v node_modules + ``` + + Note: Many of these imports will continue to work via the new `src/utils/index.ts` barrel. Only imports that reference deleted files directly (like `from '../utils/index'`, `from '../common-ui-utils'`, `from '../common-ui-utils/commonUiUtils'`) **must** be updated. + +- [ ] **Step 4: Delete old source files** + + ```bash + rm -rf src/common-ui-utils/ + rm src/utils/equalityChecks.ts + rm src/utils/fetch.ts + rm src/utils/WalletConnectionState.ts + rm src/utils/enum.ts + rm src/utils/strings.ts + rm src/utils/validation.ts + rm src/utils/token.ts + rm src/utils/math.ts + ``` + + Note: Do NOT delete `src/utils/index.ts` — it was replaced in Step 1 with the new barrel. + +- [ ] **Step 5: Run TypeScript compiler** + + Run: `npx tsc --noEmit` + Fix all errors. Common issues will be: + - Missing imports from old paths + - Circular dependency issues + - Duplicate identifier errors + +- [ ] **Step 6: Run tests** + + Run: `npm test` + All 114 tests should pass. + +- [ ] **Step 7: Commit** + + ```bash + git add -A + git commit -m "refactor(common-ts): rewrite barrels, fix internal imports, delete old files" + ``` + +--- + +## Task 14: Update test imports + +**Files:** +- Modify: `tests/utils/stringUtils.test.ts` +- Modify: `tests/utils/orders.test.ts` +- Modify: `tests/utils/enumUtils.test.ts` +- Modify: `tests/utils/candles.test.ts` +- Modify: `tests/utils/equalityChecks.test.ts` +- Modify: `tests/drift/Drift/clients/CentralServerDrift/driftClientContextWrapper.test.ts` + +No changes needed for: `tests/utils/math.test.ts` (barrel still works), `tests/utils/CircularBuffer.test.ts` (`CircularBuffers/` is unchanged). + +- [ ] **Step 1: Update test imports to use new paths** + + - `stringUtils.test.ts`: change `from '../../src/common-ui-utils/commonUiUtils'` → `from '../../src/utils/strings'` (barrel re-exports `abbreviateAddress`, `trimTrailingZeros`). Keep `from '../../src/utils/strings'` for `abbreviateAccountName`. + - `orders.test.ts`: change `from '../../src/common-ui-utils/commonUiUtils'` → `from '../../src/_deprecated/common-ui-utils'` (tests use `COMMON_UI_UTILS` namespace) + - `enumUtils.test.ts`: change `from '../../src/utils'` → `from '../../src/utils/enum'` + - `candles.test.ts`: change `from '../../src/utils'` → import `PartialUISerializableOrderActionRecord` from `'../../src/utils/orders'` (use barrel, not specific file) + - `equalityChecks.test.ts`: change `from '../../src/utils/equalityChecks'` → `from '../../src/utils/core/equality'` + - `driftClientContextWrapper.test.ts`: change `from '../../../../../src/utils'` → `from '../../../../../src/utils/core'` (imports `sleep`) + +- [ ] **Step 2: Run tests** + + Run: `npm test` + All 114 tests should pass. + +- [ ] **Step 3: Commit** + + ```bash + git add tests/ + git commit -m "refactor(common-ts): update test imports for new module structure" + ``` + +--- + +## Task 15: Update `package.json` with exports and typesVersions + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Add `exports` field to `package.json`** + + Add the full exports map with `types` and `default` conditions for each subpath as specified in the design spec. + +- [ ] **Step 2: Add `typesVersions` field** + + Add the `typesVersions` fallback for `moduleResolution: "node"` consumers as specified in the design spec. + +- [ ] **Step 3: Run build** + + Run: `npm run build` + Verify `lib/` output contains all expected directories and files. + +- [ ] **Step 4: Verify subpath exports resolve** + + Manually check that `lib/utils/math/index.js` and `lib/utils/math/index.d.ts` exist. + +- [ ] **Step 5: Commit** + + ```bash + git add package.json + git commit -m "refactor(common-ts): add subpath exports and typesVersions to package.json" + ``` + +--- + +## Task 16: Check for circular dependencies and final verification + +- [ ] **Step 1: Run circular dependency check** + + Run: `npm run circular-deps` + (This runs `bunx madge --circular --extensions ts,tsx src`) + + If cycles are found, resolve them by adjusting imports. + +- [ ] **Step 2: Run full build** + + Run: `npm run build` + +- [ ] **Step 3: Run all tests** + + Run: `npm test` + +- [ ] **Step 4: Verify deprecated exports work** + + Create a temporary test file that imports `COMMON_UI_UTILS`, `COMMON_UTILS`, `TRADING_UTILS`, `MARKET_UTILS`, `ORDER_COMMON_UTILS`, `USER_UTILS`, `COMMON_MATH`, `EQUALITY_CHECKS` from the main barrel and verify they have the expected members. + +- [ ] **Step 5: Final commit** + + ```bash + git add -A + git commit -m "refactor(common-ts): finalize utils restructure, verify no circular deps" + ``` diff --git a/common-ts/docs/superpowers/specs/2026-03-26-utils-restructure-design.md b/common-ts/docs/superpowers/specs/2026-03-26-utils-restructure-design.md new file mode 100644 index 00000000..f1b3c996 --- /dev/null +++ b/common-ts/docs/superpowers/specs/2026-03-26-utils-restructure-design.md @@ -0,0 +1,489 @@ +# Utils Restructure Design + +## Problem + +The `@drift-labs/common` package has a messy utility structure that makes it hard for both internal teams and external integrators to discover, import, and understand available utilities. + +**Key issues identified in audit:** + +1. **Poor discoverability** — utilities are scattered across `utils/`, `common-ui-utils/`, and `drift/utils/` with no clear organization principle +2. **God files** — `utils/index.ts` is 865 lines mixing pure utilities with domain-specific Drift protocol logic +3. **Namespace bag pattern** — `COMMON_UTILS`, `COMMON_UI_UTILS` aggregate unrelated functions into monolithic objects, making autocomplete and docs useless +4. **Duplicated functions** — `chunks` exists in both `utils/index.ts` and `common-ui-utils/commonUiUtils.ts`; `getTokenAddress`/`getTokenAccount` exist in both `utils/token.ts` and `common-ui-utils/commonUiUtils.ts` +5. **Misleading naming** — `common-ui-utils/` contains zero browser/UI API usage; it's trading domain logic +6. **Barrel file chaos** — `src/index.ts` has 47 re-exports, some duplicated (`priority-fees` exported twice), some bypassing barrel files +7. **No subpath exports** — only `"."` and `"./clients"` in `package.json` exports field + +**Consumers:** NextJS web app, React Native app, and external integrators via npm. + +## Goals + +- Reorganize utilities into domain-based modules under `src/utils/` +- Add subpath exports so users can import from `@drift-labs/common/utils/math` etc. +- Deduplicate functions with a single source of truth +- Preserve backwards compatibility via deprecation facades (`COMMON_UI_UTILS`, `COMMON_UTILS`, etc.) +- Keep the build simple (`tsc` only, no bundler) +- Export everything from the main entry point (no public/internal gating) + +## Non-Goals + +- Tree-shaking optimization (future work) +- Bundler migration (future work) +- Public vs internal API gating (future work) +- Breaking changes to existing imports (deprecate-then-remove strategy) + +## New Directory Structure + +``` +src/ + utils/ + # ─── Domain Modules (each has index.ts barrel) ─── + + math/ + index.ts + numbers.ts # calculateMean, calculateMedian, calculateStandardDeviation, + # calculateZScore, getPctCompletion, roundToDecimal, aprFromApy + bn.ts # bnMin, bnMax, bnMean, bnMedian, sortBnAsc, sortBnDesc + bignum.ts # roundBigNumToDecimalPlace, getBigNumRoundedToStepSize + precision.ts # roundToStepSize, roundToStepSizeIfLargeEnough, + # truncateInputToPrecision, valueIsBelowStepSize, + # numbersFitEvenly, dividesExactly, TRADE_PRECISION + spread.ts # calculateSpread, calculateSpreadBidAskMark, calculateMarkPrice, + # calculateBidAskAndmarkPrice (+ private helpers: + # calculateSpreadQuote, calculateSpreadPct) + price.ts # getPriceForBaseAndQuoteAmount, getPriceForOrderRecord, + # getPriceForUIOrderRecord, calculateAverageEntryPrice + sort.ts # getTieredSortScore, sortRecordsByTs + + strings/ + index.ts + format.ts # abbreviateAddress, abbreviateAccountName, trimTrailingZeros, + # toSnakeCase, toCamelCase, normalizeBaseAssetSymbol + parse.ts # splitByCapitalLetters, lowerCaseNonFirstWords, + # disallowNegativeStringInput, isValidBase58 + convert.ts # toPrintableObject, convertStringValuesToNumbers, + # extractStringValuesFromObject + status.ts # lastOrderStatusToNormalEng, LastOrderStatus, LastOrderStatusLabel, + # LAST_ORDER_STATUS_LABELS + + enum/ + index.ts # matchEnum, ENUM_UTILS (unchanged) + + validation/ + index.ts + address.ts # isValidPublicKey, isValidAddressForChain, isValidBase58 (re-export + # from strings), isValidEvmAddress, isValidBitcoinAddress, + # isValidTronAddress, isValidSolanaAddress, getAddressFormatHint, + # isEvmChain, chain ID constants + notional.ts # isNotionalDust + input.ts # formatTokenInputCurried + + token/ + index.ts + address.ts # getTokenAddress (canonical, PublicKey params), + # getTokenAddressForDepositAndWithdraw + account.ts # getTokenAccount (canonical), getBalanceFromTokenAccountResult + instructions.ts # createTokenAccountIx, re-export TOKEN_PROGRAM_ID, + # createTransferCheckedInstruction + + trading/ + index.ts + auction.ts # getMarketAuctionParams, getLimitAuctionParams, + # deriveMarketOrderParams, getPriceObject + pnl.ts # calculatePnlPctFromPosition, calculatePotentialProfit, + # POTENTIAL_PROFIT_DEFAULT_STATE + liquidation.ts # calculateLiquidationPriceAfterPerpTrade + leverage.ts # convertLeverageToMarginRatio, convertMarginRatioToLeverage, + # validateLeverageChange, getMarginUsedForPosition + lp.ts # getLpSharesAmountForQuote, getQuoteValueForLpShares + price.ts # getMarketOrderLimitPrice, checkIsMarketOrderType + size.ts # getMarketTickSize, getMarketTickSizeDecimals, + # getMarketStepSize, getMarketStepSizeDecimals, + # isEntirePositionOrder, getMaxLeverageOrderSize, formatOrderSize + + markets/ + index.ts + config.ts # getMarketConfig, getBaseAssetSymbol + leverage.ts # getMaxLeverageForMarket, getMaxLeverageForMarketAccount + operations.ts # getPausedOperations, PerpOperationsMap, SpotOperationsMap, + # InsuranceFundOperationsMap + interest.ts # getCurrentOpenInterestForMarket, getDepositAprForMarket, + # getBorrowAprForMarket + balances.ts # getTotalBorrowsForMarket, getTotalDepositsForMarket + precisions.ts # (moved from utils/markets/precisions.ts) + + orders/ + index.ts + labels.ts # getOrderLabelFromOrderDetails, getUIOrderTypeFromSdkOrderType + filters.ts # orderActionRecordIsTrade, uiOrderActionRecordIsTrade, + # filterTradeRecordsFromOrderActionRecords, + # filterTradeRecordsFromUIOrderRecords, isOrderTriggered + sort.ts # getSortScoreForOrderRecords, getSortScoreForOrderActionRecords, + # sortUIMatchedOrderRecordAndAction, sortUIOrderActionRecords, + # sortUIOrderRecords, sortOrderRecords, + # getLatestOfTwoUIOrderRecords, getLatestOfTwoOrderRecords, + # getUIOrderRecordsLaterThanTarget + oracle.ts # getLimitPriceFromOracleOffset, isAuctionEmpty + flags.ts # getPerpOrderParamsBitFlags, getPerpAuctionDuration, + # HighLeverageOptions (type) + misc.ts # orderIsNull, getTradeInfoFromActionRecord, getAnchorEnumString + + positions/ + index.ts + open.ts # getOpenPositionData + user.ts # checkIfUserAccountExists, getUserMaxLeverageForMarket + + accounts/ + index.ts + init.ts # initializeAndSubscribeToNewUserAccount, + # awaitAccountInitializationChainState, updateUserAccount (private), + # ACCOUNT_INITIALIZATION_RETRY_DELAY_MS, + # ACCOUNT_INITIALIZATION_RETRY_ATTEMPTS + keys.ts # getUserKey, getIdAndAuthorityFromKey, getMarketKey + # (includes uiStringCache + getCachedUiString private helpers, + # shared with abbreviateAddress in strings/format.ts via + # a shared cache utility in core/) + subaccounts.ts # fetchCurrentSubaccounts, fetchUserClientsAndAccounts, userExists + wallet.ts # createPlaceholderIWallet, WalletConnectionState (class + enums) + signature.ts # verifySignature, hashSignature, compareSignatures, + # getSignatureVerificationMessageForSettings + multiple.ts # getMultipleAccounts, getMultipleAccountsCore, + # getMultipleAccountsInfoChunked + + core/ + index.ts + async.ts # sleep, timedPromise + arrays.ts # chunks (canonical, deduplicated), glueArray + data-structures.ts # Ref, Counter, MultiSwitch + equality.ts # arePropertiesEqual, areTwoOpenPositionsEqual, + # areOpenPositionListsEqual, EQUALITY_CHECKS + cache.ts # uiStringCache, getCachedUiString (shared by strings/format.ts + # and accounts/keys.ts) + fetch.ts # encodeQueryParams + serialization.ts # encodeStringifiableObject, decodeStringifiableObject + + # ─── Non-domain files (stay at utils/ level, not in a domain module) ─── + logger.ts # unchanged (Node/winston) + featureFlags.ts # FEATURE_FLAGS (note: camelCase filename, not hyphenated) + geoblock/ # checkGeoBlock + settings/ # VersionedSettingsHandler (moved from common-ui-utils/settings) + priority-fees/ # PriorityFeeCalculator, strategies + candles/ # Candle, types + orderbook/ # orderbook utils + CircularBuffers/ # CircularBuffer, UniqueCircularBuffer + dlob-server/ # DlobServerWebsocketUtils + + # ─── Files that stay at utils/ level (small, standalone) ─── + rxjs.ts + rpcLatency.ts + driftEvents.ts + pollingSequenceGuard.ts + priorityFees.ts + NumLib.ts + millify.ts + MultiplexWebSocket.ts + SharedInterval.ts + Stopwatch.ts + SlotBasedResultValidator.ts + ResultSlotIncrementer.ts + signedMsgs.ts + s3Buckets.ts + superstake.ts + priceImpact.ts + assert.ts + insuranceFund.ts + StrictEventEmitter.ts + + # ─── Deprecation Facades ─── + _deprecated/ + utils.ts # Reconstructs COMMON_UTILS by importing from new locations + common-ui-utils.ts # Reconstructs COMMON_UI_UTILS (+ TRADING_UTILS, MARKET_UTILS, + # ORDER_COMMON_UTILS, USER_UTILS spread in) + common-math.ts # Reconstructs COMMON_MATH namespace + equality-checks.ts # Reconstructs EQUALITY_CHECKS namespace + trading-utils.ts # Reconstructs TRADING_UTILS namespace + market-utils.ts # Reconstructs MARKET_UTILS namespace + order-utils.ts # Reconstructs ORDER_COMMON_UTILS namespace + user-utils.ts # Reconstructs USER_UTILS namespace + + # ─── Unchanged ─── + clients/ + constants/ + drift/ # drift/utils/ stays as-is + types/ + actions/ + Config.ts + EnvironmentConstants.ts + chartConstants.ts + serializableTypes.ts +``` + +## Deprecation Facades + +### `_deprecated/utils.ts` + +Reconstructs the `COMMON_UTILS` namespace object by importing each function from its new canonical location: + +```ts +import { getIfVaultBalance, getIfStakingVaultApr } from '../utils/...'; +import { getCurrentOpenInterestForMarket, ... } from '../utils/markets'; +import { dividesExactly } from '../utils/math'; +import { toSnakeCase, toCamelCase, normalizeBaseAssetSymbol } from '../utils/strings'; +import { getTieredSortScore } from '../utils/math'; +import { calculateMean, calculateMedian, bnMax, bnMin, bnMean, bnMedian } from '../utils/math'; +import { chunks, glueArray, timedPromise } from '../utils/core'; + +/** @deprecated Use direct imports from '@drift-labs/common/utils/math' etc. */ +export const COMMON_UTILS = { + getIfVaultBalance, + getIfStakingVaultApr, + getCurrentOpenInterestForMarket, + // ... all existing members + MATH: { + NUM: { mean: calculateMean, median: calculateMedian }, + BN: { bnMax, bnMin, bnMean, bnMedian }, + }, +}; +``` + +### `_deprecated/common-ui-utils.ts` + +Reconstructs `COMMON_UI_UTILS` by importing from new locations and spreading in `USER_UTILS`, `TRADING_UTILS`, `MARKET_UTILS`, `ORDER_COMMON_UTILS`: + +```ts +import { abbreviateAddress } from '../utils/strings'; +import { deriveMarketOrderParams, ... } from '../utils/trading'; +import { getUserKey, getMarketKey, ... } from '../utils/accounts'; +// ... etc + +/** @deprecated Use direct imports from specific modules */ +export const COMMON_UI_UTILS = { + abbreviateAddress, + calculateAverageEntryPrice, + chunks, + // ... all existing members including spreads +}; +``` + +Also re-exports the individual namespace objects: +```ts +export { TRADING_UTILS } from './trading-utils'; +export { MARKET_UTILS } from './market-utils'; +export { ORDER_COMMON_UTILS } from './order-utils'; +export { USER_UTILS } from './user-utils'; +``` + +### Deprecated Types + +Types that were previously exported alongside deprecated utils (e.g., `PartialOrderActionRecord`, `PartialUISerializableOrderActionRecord`, `HighLeverageOptions`) move to the new domain modules and are re-exported from the deprecation facade. + +## Deduplication + +| Function | Current locations | Canonical location | +|---|---|---| +| `chunks` | `utils/index.ts`, `common-ui-utils/commonUiUtils.ts` | `utils/core/arrays.ts` | +| `getTokenAddress` | `utils/token.ts` (string params), `common-ui-utils/commonUiUtils.ts` (PublicKey params) | `utils/token/address.ts` — keep both signatures, export from same file | +| `getTokenAccount` | `utils/token.ts` (string params), `common-ui-utils/commonUiUtils.ts` (PublicKey params + warning) | `utils/token/account.ts` — keep the richer version (with warning detection), add string-param overload | +| `getLatestOfTwoUIOrderRecords` / `getLatestOfTwoOrderRecords` | `utils/index.ts` — identical implementations | `utils/orders/sort.ts` — keep both names for compat, but they call same underlying function | + +## `package.json` Changes + +### Exports field + +Each subpath entry includes both `types` and `default` conditions for proper TypeScript +resolution with `moduleResolution: "node16"` or `"nodenext"`: + +```json +{ + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./clients": { + "types": "./lib/clients/index.d.ts", + "default": "./lib/clients/index.js" + }, + "./utils/math": { + "types": "./lib/utils/math/index.d.ts", + "default": "./lib/utils/math/index.js" + }, + "./utils/strings": { + "types": "./lib/utils/strings/index.d.ts", + "default": "./lib/utils/strings/index.js" + }, + "./utils/enum": { + "types": "./lib/utils/enum/index.d.ts", + "default": "./lib/utils/enum/index.js" + }, + "./utils/validation": { + "types": "./lib/utils/validation/index.d.ts", + "default": "./lib/utils/validation/index.js" + }, + "./utils/token": { + "types": "./lib/utils/token/index.d.ts", + "default": "./lib/utils/token/index.js" + }, + "./utils/trading": { + "types": "./lib/utils/trading/index.d.ts", + "default": "./lib/utils/trading/index.js" + }, + "./utils/markets": { + "types": "./lib/utils/markets/index.d.ts", + "default": "./lib/utils/markets/index.js" + }, + "./utils/orders": { + "types": "./lib/utils/orders/index.d.ts", + "default": "./lib/utils/orders/index.js" + }, + "./utils/positions": { + "types": "./lib/utils/positions/index.d.ts", + "default": "./lib/utils/positions/index.js" + }, + "./utils/accounts": { + "types": "./lib/utils/accounts/index.d.ts", + "default": "./lib/utils/accounts/index.js" + }, + "./utils/core": { + "types": "./lib/utils/core/index.d.ts", + "default": "./lib/utils/core/index.js" + } + } +} +``` + +### typesVersions (fallback for `moduleResolution: "node"`) + +Consumers using the legacy `moduleResolution: "node"` in their `tsconfig.json` will not +resolve subpath exports. The `typesVersions` field provides a fallback for TypeScript type +resolution in that mode: + +```json +{ + "typesVersions": { + "*": { + "utils/math": ["lib/utils/math/index.d.ts"], + "utils/strings": ["lib/utils/strings/index.d.ts"], + "utils/enum": ["lib/utils/enum/index.d.ts"], + "utils/validation": ["lib/utils/validation/index.d.ts"], + "utils/token": ["lib/utils/token/index.d.ts"], + "utils/trading": ["lib/utils/trading/index.d.ts"], + "utils/markets": ["lib/utils/markets/index.d.ts"], + "utils/orders": ["lib/utils/orders/index.d.ts"], + "utils/positions": ["lib/utils/positions/index.d.ts"], + "utils/accounts": ["lib/utils/accounts/index.d.ts"], + "utils/core": ["lib/utils/core/index.d.ts"] + } + } +} +``` + +### Browser field + +Logger browser stub has been removed — no `browser` field needed. + +## `src/index.ts` (Main Barrel) + +The main barrel re-exports everything: + +```ts +// New domain modules +export * from './utils/math'; +export * from './utils/strings'; +export * from './utils/enum'; +export * from './utils/validation'; +export * from './utils/token'; +export * from './utils/trading'; +export * from './utils/markets'; +export * from './utils/orders'; +export * from './utils/positions'; +export * from './utils/accounts'; +export * from './utils/core'; + +// Non-domain utils (unchanged paths) +export * from './utils/logger'; +export * from './utils/featureFlags'; +export * from './utils/candles/Candle'; +export * from './utils/rpcLatency'; +export * from './utils/SharedInterval'; +export * from './utils/Stopwatch'; +export * from './utils/priority-fees'; +export * from './utils/superstake'; +export * from './utils/priceImpact'; +export * from './utils/dlob-server/DlobServerWebsocketUtils'; +export * from './utils/orderbook'; +export * from './utils/pollingSequenceGuard'; +export * from './utils/driftEvents'; +export * from './utils/SlotBasedResultValidator'; +export * from './utils/CircularBuffers'; +export * from './utils/rxjs'; +export * from './utils/priorityFees'; +export * from './utils/NumLib'; +export * from './utils/s3Buckets'; +export { default as millify } from './utils/millify'; +export { getSwiftConfirmationTimeoutMs } from './utils/signedMsgs'; +export { ResultSlotIncrementer } from './utils/ResultSlotIncrementer'; +export { MultiplexWebSocket } from './utils/MultiplexWebSocket'; + +// Settings +export * from './utils/settings/settings'; + +// Deprecation facades (backwards compat) +export { COMMON_UTILS } from './_deprecated/utils'; +export { COMMON_UI_UTILS } from './_deprecated/common-ui-utils'; +export { COMMON_MATH } from './_deprecated/common-math'; +export { EQUALITY_CHECKS } from './_deprecated/equality-checks'; +export { TRADING_UTILS } from './_deprecated/trading-utils'; +export { MARKET_UTILS } from './_deprecated/market-utils'; +export { ORDER_COMMON_UTILS } from './_deprecated/order-utils'; +export { USER_UTILS } from './_deprecated/user-utils'; + +// Other unchanged exports +export * from './Config'; +export * from './chartConstants'; +export * from './types'; +export * from './EnvironmentConstants'; +export * from './serializableTypes'; +export * from './constants'; +export * from './actions/actionHelpers/actionHelpers'; +export * from './clients/candleClient'; +export * from './clients/marketDataFeed'; +export * from './clients/swiftClient'; +export * from './clients/tvFeed'; +export * from './clients/DlobWebsocketClient'; +export * from './drift'; + +// External program errors +import JupV4Errors from './constants/autogenerated/jup-v4-error-codes.json'; +import JupV6Errors from './constants/autogenerated/jup-v6-error-codes.json'; +export { JupV4Errors, JupV6Errors }; + +import DriftErrors from './constants/autogenerated/driftErrors.json'; +export { DriftErrors }; +``` + +## Migration Checklist (for implementation plan) + +1. Create all new domain module directories and files under `utils/` +2. Move functions from `utils/index.ts` into appropriate domain modules +3. Move functions from `common-ui-utils/*.ts` into appropriate domain modules +4. Deduplicate `chunks`, `getTokenAddress`, `getTokenAccount` +5. Move types alongside their functions +6. Create deprecation facade files in `_deprecated/` +7. Create new `utils/index.ts` barrel (just re-exports all domain modules) +8. Rewrite `src/index.ts` barrel +9. Update `package.json` exports and browser fields +10. Update all internal imports (`drift/`, `clients/`, etc.) to use new paths +11. Run `tsc` — fix any import/type errors +12. Run existing tests — ensure nothing breaks +13. Run `madge --circular` to check for circular dependency regressions +14. Verify old import paths still work (COMMON_UI_UTILS etc.) +15. Bump minor version (non-breaking — deprecation only) + +## Risks + +- **Circular dependencies**: Moving code between modules may introduce cycles. `madge --circular` must pass. +- **Re-export conflicts**: Two domain modules exporting the same name. The audit found no naming conflicts, but must verify during implementation. Specifically watch for `WalletConnectionState` (must be exported from `accounts/` only, not duplicated at `utils/` level). +- **Browser field**: Logger path must be kept in sync with package.json browser field. +- **Consumers using deep imports**: Any external user importing `@drift-labs/common/lib/utils/index` directly (bypassing the exports field) will break. This is acceptable — deep `lib/` imports are not part of the public API. +- **TypeScript `moduleResolution: "node"` compatibility**: Consumers using the legacy `moduleResolution: "node"` will not resolve subpath exports. The `typesVersions` field in `package.json` provides a fallback for type resolution, but runtime resolution requires Node.js 12.7+ (not a concern given `engines: "^24.x.x"`). diff --git a/common-ts/package.json b/common-ts/package.json index 09ba85c7..17facaa3 100644 --- a/common-ts/package.json +++ b/common-ts/package.json @@ -59,8 +59,73 @@ "lib": "lib" }, "exports": { - ".": "./lib/index.js", - "./clients": "./lib/clients/index.js" + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./clients": { + "types": "./lib/clients/index.d.ts", + "default": "./lib/clients/index.js" + }, + "./utils/math": { + "types": "./lib/utils/math/index.d.ts", + "default": "./lib/utils/math/index.js" + }, + "./utils/strings": { + "types": "./lib/utils/strings/index.d.ts", + "default": "./lib/utils/strings/index.js" + }, + "./utils/enum": { + "types": "./lib/utils/enum/index.d.ts", + "default": "./lib/utils/enum/index.js" + }, + "./utils/validation": { + "types": "./lib/utils/validation/index.d.ts", + "default": "./lib/utils/validation/index.js" + }, + "./utils/token": { + "types": "./lib/utils/token/index.d.ts", + "default": "./lib/utils/token/index.js" + }, + "./utils/trading": { + "types": "./lib/utils/trading/index.d.ts", + "default": "./lib/utils/trading/index.js" + }, + "./utils/markets": { + "types": "./lib/utils/markets/index.d.ts", + "default": "./lib/utils/markets/index.js" + }, + "./utils/orders": { + "types": "./lib/utils/orders/index.d.ts", + "default": "./lib/utils/orders/index.js" + }, + "./utils/positions": { + "types": "./lib/utils/positions/index.d.ts", + "default": "./lib/utils/positions/index.js" + }, + "./utils/accounts": { + "types": "./lib/utils/accounts/index.d.ts", + "default": "./lib/utils/accounts/index.js" + }, + "./utils/core": { + "types": "./lib/utils/core/index.d.ts", + "default": "./lib/utils/core/index.js" + } + }, + "typesVersions": { + "*": { + "utils/math": ["lib/utils/math/index.d.ts"], + "utils/strings": ["lib/utils/strings/index.d.ts"], + "utils/enum": ["lib/utils/enum/index.d.ts"], + "utils/validation": ["lib/utils/validation/index.d.ts"], + "utils/token": ["lib/utils/token/index.d.ts"], + "utils/trading": ["lib/utils/trading/index.d.ts"], + "utils/markets": ["lib/utils/markets/index.d.ts"], + "utils/orders": ["lib/utils/orders/index.d.ts"], + "utils/positions": ["lib/utils/positions/index.d.ts"], + "utils/accounts": ["lib/utils/accounts/index.d.ts"], + "utils/core": ["lib/utils/core/index.d.ts"] + } }, "files": [ "lib" diff --git a/common-ts/src/_deprecated/common-math.ts b/common-ts/src/_deprecated/common-math.ts new file mode 100644 index 00000000..7851fd52 --- /dev/null +++ b/common-ts/src/_deprecated/common-math.ts @@ -0,0 +1,6 @@ +import { calculateSpreadBidAskMark } from '../utils/math/spread'; + +/** @deprecated Use direct import from '@drift-labs/common/utils/math' */ +export const COMMON_MATH = { + calculateSpreadBidAskMark, +}; diff --git a/common-ts/src/_deprecated/common-ui-utils.ts b/common-ts/src/_deprecated/common-ui-utils.ts new file mode 100644 index 00000000..a719cea6 --- /dev/null +++ b/common-ts/src/_deprecated/common-ui-utils.ts @@ -0,0 +1,83 @@ +import { abbreviateAddress, trimTrailingZeros } from '../utils/strings/format'; +import { calculateAverageEntryPrice } from '../utils/math/price'; +import { chunks } from '../utils/core/arrays'; +import { + compareSignatures, + getSignatureVerificationMessageForSettings, + verifySignature, + hashSignature, +} from '../utils/accounts/signature'; +import { createPlaceholderIWallet } from '../utils/accounts/wallet'; +import { + deriveMarketOrderParams, + getLimitAuctionParams, + getMarketAuctionParams, + getPriceObject, +} from '../utils/trading/auction'; +import { + fetchCurrentSubaccounts, + fetchUserClientsAndAccounts, + userExists, +} from '../utils/accounts/subaccounts'; +import { formatTokenInputCurried } from '../utils/validation/input'; +import { + getBalanceFromTokenAccountResult, + getTokenAccount, +} from '../utils/token/account'; +import { + getIdAndAuthorityFromKey, + getUserKey, + getMarketKey, +} from '../utils/accounts/keys'; +import { + getLpSharesAmountForQuote, + getQuoteValueForLpShares, +} from '../utils/trading/lp'; +import { getMarketOrderLimitPrice } from '../utils/trading/price'; +import { + getMultipleAccounts, + getMultipleAccountsCore, +} from '../utils/accounts/multiple'; +import { getTokenAddress } from '../utils/token/address'; +import { initializeAndSubscribeToNewUserAccount } from '../utils/accounts/init'; +import { USER_UTILS } from './user-utils'; +import { TRADING_UTILS } from './trading-utils'; +import { MARKET_UTILS } from './market-utils'; +import { ORDER_COMMON_UTILS } from './order-utils'; + +/** @deprecated Use direct imports from '@drift-labs/common/utils/trading', '@drift-labs/common/utils/accounts', etc. */ +export const COMMON_UI_UTILS = { + abbreviateAddress, + calculateAverageEntryPrice, + chunks, + compareSignatures, + createPlaceholderIWallet, + deriveMarketOrderParams, + fetchCurrentSubaccounts, + fetchUserClientsAndAccounts, + formatTokenInputCurried, + getBalanceFromTokenAccountResult, + getIdAndAuthorityFromKey, + getLimitAuctionParams, + getLpSharesAmountForQuote, + getMarketAuctionParams, + getMarketKey, + getMarketOrderLimitPrice, + getMultipleAccounts, + getMultipleAccountsCore, + getPriceObject, + getQuoteValueForLpShares, + getSignatureVerificationMessageForSettings, + getTokenAccount, + getTokenAddress, + getUserKey, + hashSignature, + initializeAndSubscribeToNewUserAccount, + userExists, + verifySignature, + trimTrailingZeros, + ...USER_UTILS, + ...TRADING_UTILS, + ...MARKET_UTILS, + ...ORDER_COMMON_UTILS, +}; diff --git a/common-ts/src/_deprecated/equality-checks.ts b/common-ts/src/_deprecated/equality-checks.ts new file mode 100644 index 00000000..f1e9ae5d --- /dev/null +++ b/common-ts/src/_deprecated/equality-checks.ts @@ -0,0 +1,2 @@ +/** @deprecated Use direct import from '@drift-labs/common/utils/core/equality' */ +export { EQUALITY_CHECKS } from '../utils/core/equality'; diff --git a/common-ts/src/_deprecated/market-utils.ts b/common-ts/src/_deprecated/market-utils.ts new file mode 100644 index 00000000..9bc00754 --- /dev/null +++ b/common-ts/src/_deprecated/market-utils.ts @@ -0,0 +1,23 @@ +import { getBaseAssetSymbol, getMarketConfig } from '../utils/markets/config'; +import { + getPausedOperations, + PerpOperationsMap, + SpotOperationsMap, + InsuranceFundOperationsMap, +} from '../utils/markets/operations'; +import { + getMaxLeverageForMarket, + getMaxLeverageForMarketAccount, +} from '../utils/markets/leverage'; + +/** @deprecated Use direct imports from '@drift-labs/common/utils/markets' */ +export const MARKET_UTILS = { + getBaseAssetSymbol, + getPausedOperations, + PerpOperationsMap, + SpotOperationsMap, + InsuranceFundOperationsMap, + getMarketConfig, + getMaxLeverageForMarket, + getMaxLeverageForMarketAccount, +}; diff --git a/common-ts/src/_deprecated/order-utils.ts b/common-ts/src/_deprecated/order-utils.ts new file mode 100644 index 00000000..0618089c --- /dev/null +++ b/common-ts/src/_deprecated/order-utils.ts @@ -0,0 +1,24 @@ +import { + getOrderLabelFromOrderDetails, + getUIOrderTypeFromSdkOrderType, +} from '../utils/orders/labels'; +import { + getLimitPriceFromOracleOffset, + isAuctionEmpty, +} from '../utils/orders/oracle'; +import { + getPerpAuctionDuration, + getPerpOrderParamsBitFlags, +} from '../utils/orders/flags'; +import { isOrderTriggered } from '../utils/orders/filters'; + +/** @deprecated Use direct imports from '@drift-labs/common/utils/orders' */ +export const ORDER_COMMON_UTILS = { + getOrderLabelFromOrderDetails, + getLimitPriceFromOracleOffset, + isAuctionEmpty, + getUIOrderTypeFromSdkOrderType, + getPerpAuctionDuration, + getPerpOrderParamsBitFlags, + isOrderTriggered, +}; diff --git a/common-ts/src/_deprecated/trading-utils.ts b/common-ts/src/_deprecated/trading-utils.ts new file mode 100644 index 00000000..16d92dfe --- /dev/null +++ b/common-ts/src/_deprecated/trading-utils.ts @@ -0,0 +1,40 @@ +import { + calculatePnlPctFromPosition, + calculatePotentialProfit, +} from '../utils/trading/pnl'; +import { calculateLiquidationPriceAfterPerpTrade } from '../utils/trading/liquidation'; +import { checkIsMarketOrderType } from '../utils/trading/price'; +import { + convertLeverageToMarginRatio, + convertMarginRatioToLeverage, + getMarginUsedForPosition, + validateLeverageChange, +} from '../utils/trading/leverage'; +import { + getMarketTickSize, + getMarketTickSizeDecimals, + getMarketStepSize, + getMarketStepSizeDecimals, + isEntirePositionOrder, + getMaxLeverageOrderSize, + formatOrderSize, +} from '../utils/trading/size'; + +/** @deprecated Use direct imports from '@drift-labs/common/utils/trading' */ +export const TRADING_UTILS = { + calculatePnlPctFromPosition, + calculatePotentialProfit, + calculateLiquidationPriceAfterPerpTrade, + checkIsMarketOrderType, + convertLeverageToMarginRatio, + convertMarginRatioToLeverage, + getMarketTickSize, + getMarketTickSizeDecimals, + getMarketStepSize, + getMarketStepSizeDecimals, + isEntirePositionOrder, + getMaxLeverageOrderSize, + formatOrderSize, + getMarginUsedForPosition, + validateLeverageChange, +}; diff --git a/common-ts/src/_deprecated/user-utils.ts b/common-ts/src/_deprecated/user-utils.ts new file mode 100644 index 00000000..74a46aef --- /dev/null +++ b/common-ts/src/_deprecated/user-utils.ts @@ -0,0 +1,12 @@ +import { getOpenPositionData } from '../utils/positions/open'; +import { + checkIfUserAccountExists, + getUserMaxLeverageForMarket, +} from '../utils/positions/user'; + +/** @deprecated Use direct imports from '@drift-labs/common/utils/positions' */ +export const USER_UTILS = { + getOpenPositionData, + checkIfUserAccountExists, + getUserMaxLeverageForMarket, +}; diff --git a/common-ts/src/_deprecated/utils.ts b/common-ts/src/_deprecated/utils.ts new file mode 100644 index 00000000..ac56e172 --- /dev/null +++ b/common-ts/src/_deprecated/utils.ts @@ -0,0 +1,62 @@ +import { + getIfVaultBalance, + getIfStakingVaultApr, +} from '../utils/insuranceFund'; +import { + getCurrentOpenInterestForMarket, + getDepositAprForMarket, + getBorrowAprForMarket, +} from '../utils/markets/interest'; +import { + getTotalBorrowsForMarket, + getTotalDepositsForMarket, +} from '../utils/markets/balances'; +import { dividesExactly } from '../utils/math/precision'; +import { + toSnakeCase, + toCamelCase, + normalizeBaseAssetSymbol, +} from '../utils/strings/format'; +import { getTieredSortScore } from '../utils/math/sort'; +import { + calculateZScore, + calculateMean, + calculateMedian, +} from '../utils/math/numbers'; +import { chunks, glueArray } from '../utils/core/arrays'; +import { timedPromise } from '../utils/core/async'; +import { getMultipleAccountsInfoChunked } from '../utils/accounts/multiple'; +import { bnMax, bnMin, bnMean, bnMedian } from '../utils/math/bn'; + +/** @deprecated Use direct imports from '@drift-labs/common/utils/math', '@drift-labs/common/utils/core', etc. */ +export const COMMON_UTILS = { + getIfVaultBalance, + getIfStakingVaultApr, + getCurrentOpenInterestForMarket, + getDepositAprForMarket, + getBorrowAprForMarket, + getTotalBorrowsForMarket, + getTotalDepositsForMarket, + dividesExactly, + toSnakeCase, + toCamelCase, + getTieredSortScore, + normalizeBaseAssetSymbol, + calculateZScore, + glueArray, + timedPromise, + chunks, + getMultipleAccountsInfoChunked, + MATH: { + NUM: { + mean: calculateMean, + median: calculateMedian, + }, + BN: { + bnMax, + bnMin, + bnMean, + bnMedian, + }, + }, +}; diff --git a/common-ts/src/clients/tvFeed.ts b/common-ts/src/clients/tvFeed.ts index 1b442fec..bf2e96ff 100644 --- a/common-ts/src/clients/tvFeed.ts +++ b/common-ts/src/clients/tvFeed.ts @@ -10,7 +10,7 @@ import { UIEnv } from '../types/UIEnv'; import { Candle } from '../utils/candles/Candle'; import { PollingSequenceGuard } from '../utils/pollingSequenceGuard'; import { CandleClient } from './candleClient'; -import { MARKET_UTILS } from '../common-ui-utils/market'; +import { MARKET_UTILS } from '../_deprecated/market-utils'; const DRIFT_V2_START_TS = 1668470400; // 15th November 2022 ... 2022-11-15T00:00:00.000Z diff --git a/common-ts/src/common-ui-utils/commonUiUtils.ts b/common-ts/src/common-ui-utils/commonUiUtils.ts deleted file mode 100644 index e75737a8..00000000 --- a/common-ts/src/common-ui-utils/commonUiUtils.ts +++ /dev/null @@ -1,1045 +0,0 @@ -import { - AMM_RESERVE_PRECISION_EXP, - AMM_TO_QUOTE_PRECISION_RATIO, - BASE_PRECISION_EXP, - BN, - BigNum, - DriftClient, - IWalletV2, - MarketType, - OptionalOrderParams, - OrderType, - PRICE_PRECISION, - PRICE_PRECISION_EXP, - PositionDirection, - PublicKey, - QUOTE_PRECISION_EXP, - SpotMarketConfig, - User, - UserAccount, - ZERO, - deriveOracleAuctionParams, - getMarketOrderParams, - isVariant, -} from '@drift-labs/sdk'; -import { ENUM_UTILS, sleep } from '../utils'; -import { - AccountInfo, - Connection, - Keypair, - ParsedAccountData, -} from '@solana/web3.js'; -import bcrypt from 'bcryptjs-react'; -import nacl, { sign } from 'tweetnacl'; -import { getAssociatedTokenAddress } from '@solana/spl-token'; -import { AuctionParams, TradeOffsetPrice } from 'src/types'; -import { USER_UTILS } from './user'; -import { TRADING_UTILS } from './trading'; -import { MARKET_UTILS } from './market'; -import { ORDER_COMMON_UTILS } from './order'; -import { EMPTY_AUCTION_PARAMS } from '../constants/trade'; - -// Cache for common UI string patterns to reduce memory allocation -const uiStringCache = new Map(); -const MAX_UI_STRING_CACHE_SIZE = 2000; - -// Helper function to cache common string patterns -function getCachedUiString( - pattern: string, - ...values: (string | number)[] -): string { - const cacheKey = `${pattern}:${values.join(':')}`; - - if (uiStringCache.has(cacheKey)) { - return uiStringCache.get(cacheKey)!; - } - - let result: string; - switch (pattern) { - case 'abbreviate': { - const [authString, length] = values as [string, number]; - result = `${authString.slice(0, length)}\u2026${authString.slice( - -length - )}`; - break; - } - case 'userKey': { - const [userId, authority] = values as [number, string]; - result = `${userId}_${authority}`; - break; - } - case 'marketKey': { - const [marketType, marketIndex] = values as [string, number]; - result = `${marketType}_${marketIndex}`; - break; - } - default: - result = values.join('_'); - } - - // Cache if not too large - if (uiStringCache.size < MAX_UI_STRING_CACHE_SIZE) { - uiStringCache.set(cacheKey, result); - } - - return result; -} - -// When creating an account, try 5 times over 5 seconds to wait for the new account to hit the blockchain. -const ACCOUNT_INITIALIZATION_RETRY_DELAY_MS = 1000; -const ACCOUNT_INITIALIZATION_RETRY_ATTEMPTS = 5; - -export const abbreviateAddress = (address: string | PublicKey, length = 4) => { - if (!address) return ''; - const authString = address.toString(); - return getCachedUiString('abbreviate', authString, length); -}; - -/** - * Get a unique key for an authority's subaccount - * @param userId - * @param authority - * @returns - */ -const getUserKey = (userId: number, authority: PublicKey) => { - if (userId == undefined || !authority) return ''; - return getCachedUiString('userKey', userId, authority.toString()); -}; - -/** - * Get the authority and subAccountId from a user's account key - * @param key - * @returns - */ -const getIdAndAuthorityFromKey = ( - key: string -): - | { userId: number; userAuthority: PublicKey } - | { userId: undefined; userAuthority: undefined } => { - const splitKey = key?.split('_'); - - if (!splitKey || splitKey.length !== 2) - return { userId: undefined, userAuthority: undefined }; - - return { - userId: Number(splitKey[0]), - userAuthority: new PublicKey(splitKey[1]), - }; -}; - -const fetchCurrentSubaccounts = (driftClient: DriftClient): UserAccount[] => { - return driftClient.getUsers().map((user) => user.getUserAccount()); -}; - -const fetchUserClientsAndAccounts = ( - driftClient: DriftClient -): { user: User; userAccount: UserAccount }[] => { - const accounts = fetchCurrentSubaccounts(driftClient); - const allUsersAndUserAccounts = accounts.map((acct) => { - return { - user: driftClient.getUser(acct.subAccountId, acct.authority), - userAccount: acct, - }; - }); - - return allUsersAndUserAccounts; -}; - -const awaitAccountInitializationChainState = async ( - driftClient: DriftClient, - userId: number, - authority: PublicKey -) => { - const user = driftClient.getUser(userId, authority); - - if (!user.isSubscribed) { - await user.subscribe(); - } - - let retryCount = 0; - - do { - try { - await updateUserAccount(user); - if (user?.getUserAccountAndSlot()?.data !== undefined) { - return true; - } - } catch (err) { - retryCount++; - await sleep(ACCOUNT_INITIALIZATION_RETRY_DELAY_MS); - } - } while (retryCount < ACCOUNT_INITIALIZATION_RETRY_ATTEMPTS); - - throw new Error('awaitAccountInitializationFailed'); -}; - -/** - * Using your own callback to do the account initialization, this method will run the initialization step, switch to the drift user, await for the account to be available on chain, subscribe to the user account, and switch to the user account using the drift client. - * - * It provides extra callbacks to handle steps directly after the initialiation tx, and after fully initializing+subscribing to the account. - * - * Callbacks available: - * - initializationStep: This callback should send the transaction to initialize the user account - * - postInitializationStep: This callback will run after the successful initialization transaction, but before trying to load/subscribe to the new account - * - handleSuccessStep: This callback will run after everything has initialized+subscribed successfully - * - * // TODO : Need to do the subscription step - */ -const initializeAndSubscribeToNewUserAccount = async ( - driftClient: DriftClient, - userIdToInit: number, - authority: PublicKey, - callbacks: { - initializationStep: () => Promise; - postInitializationStep?: () => Promise; - handleSuccessStep?: (accountAlreadyExisted: boolean) => Promise; - } -): Promise< - | 'ok' - | 'failed_initializationStep' - | 'failed_postInitializationStep' - | 'failed_awaitAccountInitializationChainState' - | 'failed_handleSuccessStep' -> => { - await driftClient.addUser(userIdToInit, authority); - - const accountAlreadyExisted = await driftClient - .getUser(userIdToInit) - ?.exists(); - - // Do the account initialization step - let result = await callbacks.initializationStep(); - - // Fetch account to make sure it's loaded - await updateUserAccount(driftClient.getUser(userIdToInit)); - - if (!result) { - return 'failed_initializationStep'; - } - - // Do the post-initialization step - result = callbacks.postInitializationStep - ? await callbacks.postInitializationStep() - : result; - - if (!result) { - return 'failed_postInitializationStep'; - } - - // Await the account initialization step to update the blockchain - result = await awaitAccountInitializationChainState( - driftClient, - userIdToInit, - authority - ); - - if (!result) { - return 'failed_awaitAccountInitializationChainState'; - } - - await driftClient.switchActiveUser(userIdToInit, authority); - - // Do the subscription step - - // Run the success handler - result = callbacks.handleSuccessStep - ? await callbacks.handleSuccessStep(accountAlreadyExisted) - : result; - - if (!result) { - return 'failed_handleSuccessStep'; - } - - return 'ok'; -}; - -async function updateUserAccount(user: User): Promise { - const publicKey = user.userAccountPublicKey; - try { - const dataAndContext = - await user.driftClient.program.account.user.fetchAndContext( - publicKey, - 'processed' - ); - user.accountSubscriber.updateData( - dataAndContext.data as UserAccount, - dataAndContext.context.slot - ); - } catch (e) { - // noop - } -} - -const getMarketKey = (marketIndex: number, marketType: MarketType) => - getCachedUiString('marketKey', ENUM_UTILS.toStr(marketType), marketIndex); - -/** - * Creates an IWallet wrapper, with redundant methods. If a `walletPubKey` is passed in, - * the `publicKey` will be based on that. - */ -const createPlaceholderIWallet = (walletPubKey?: PublicKey) => { - const newKeypair = walletPubKey - ? new Keypair({ - publicKey: walletPubKey.toBytes(), - secretKey: new Keypair().publicKey.toBytes(), - }) - : new Keypair(); - - const newWallet: IWalletV2 = { - publicKey: newKeypair.publicKey, - //@ts-ignore - signTransaction: () => { - return Promise.resolve(); - }, - //@ts-ignore - signAllTransactions: () => { - return Promise.resolve(); - }, - //@ts-ignore - signMessage: () => { - return Promise.resolve(); - }, - }; - - return newWallet; -}; - -const getSignatureVerificationMessageForSettings = ( - authority: PublicKey, - signTs: number -): Uint8Array => { - return new TextEncoder().encode( - `Verify you are the owner of this wallet to update trade settings: \n${authority.toBase58()}\n\nThis signature will be valid for the next 30 minutes.\n\nTS: ${signTs.toString()}` - ); -}; - -const verifySignature = ( - signature: Uint8Array, - message: Uint8Array, - pubKey: PublicKey -): boolean => { - return sign.detached.verify(message, signature, pubKey.toBytes()); -}; - -const hashSignature = async (signature: string): Promise => { - bcrypt.setRandomFallback((num: number) => { - return Array.from(nacl.randomBytes(num)); - }); - const hashedSignature = await bcrypt.hash(signature, bcrypt.genSaltSync(10)); - return hashedSignature; -}; - -const compareSignatures = async ( - original: string, - hashed: string -): Promise => { - const signaturesMatch = await bcrypt.compare(original, hashed); - return signaturesMatch; -}; - -/* Trading-related helper functions */ - -const calculateAverageEntryPrice = ( - quoteAssetAmount: BigNum, - baseAssetAmount: BigNum -): BigNum => { - if (baseAssetAmount.eqZero()) return BigNum.zero(); - - return BigNum.from( - quoteAssetAmount.val - .mul(PRICE_PRECISION) - .mul(AMM_TO_QUOTE_PRECISION_RATIO) - .div(baseAssetAmount.shiftTo(BASE_PRECISION_EXP).val) - .abs(), - PRICE_PRECISION_EXP - ); -}; - -const getMarketOrderLimitPrice = ({ - direction, - baselinePrice, - slippageTolerance, -}: { - direction: PositionDirection; - baselinePrice: BN; - slippageTolerance: number; -}): BN => { - let limitPrice; - - if (!baselinePrice) return ZERO; - - if (slippageTolerance === 0) return baselinePrice; - - // infinite slippage capped at 15% currently - if (slippageTolerance == undefined) slippageTolerance = 15; - - // if manually entered, cap at 99% - if (slippageTolerance > 99) slippageTolerance = 99; - - let limitPricePctDiff; - if (isVariant(direction, 'long')) { - limitPricePctDiff = PRICE_PRECISION.add( - new BN(slippageTolerance * PRICE_PRECISION.toNumber()).div(new BN(100)) - ); - limitPrice = baselinePrice.mul(limitPricePctDiff).div(PRICE_PRECISION); - } else { - limitPricePctDiff = PRICE_PRECISION.sub( - new BN(slippageTolerance * PRICE_PRECISION.toNumber()).div(new BN(100)) - ); - limitPrice = baselinePrice.mul(limitPricePctDiff).div(PRICE_PRECISION); - } - - return limitPrice; -}; - -const getMarketAuctionParams = ({ - direction, - startPriceFromSettings, - endPriceFromSettings, - limitPrice, - duration, - auctionStartPriceOffset, - auctionEndPriceOffset, - additionalEndPriceBuffer, - forceUpToSlippage, - bestBidPrice, - bestAskPrice, - ensureCrossingEndPrice, -}: { - direction: PositionDirection; - startPriceFromSettings: BN; - endPriceFromSettings: BN; - /** - * Limit price is the oracle limit price - market orders use the oracle order type under the hood on Drift UI - * So oracle limit price is the oracle price + oracle offset - */ - limitPrice: BN; - duration: number; - auctionStartPriceOffset: number; - auctionEndPriceOffset: number; - additionalEndPriceBuffer?: BN; - forceUpToSlippage?: boolean; - bestBidPrice?: BN; - bestAskPrice?: BN; - ensureCrossingEndPrice?: boolean; -}): AuctionParams => { - let auctionStartPrice: BN; - let auctionEndPrice: BN; - let constrainedBySlippage: boolean; - - const auctionEndPriceBuffer = BigNum.from(PRICE_PRECISION).scale( - Math.abs(auctionEndPriceOffset * 100), - 10000 - ).val; - - const auctionStartPriceBuffer = BigNum.from(startPriceFromSettings).scale( - Math.abs(auctionStartPriceOffset * 100), - 10000 - ).val; - - if (isVariant(direction, 'long')) { - auctionStartPrice = startPriceFromSettings.sub(auctionStartPriceBuffer); - - const worstPriceToUse = BN.max( - endPriceFromSettings, - startPriceFromSettings - ); // Handles edge cases like if the worst price on the book was better than the oracle price, and the settings are asking to be relative to the oracle price - - auctionEndPrice = PRICE_PRECISION.add(auctionEndPriceBuffer) - .mul(worstPriceToUse) - .div(PRICE_PRECISION); - - constrainedBySlippage = limitPrice.lt(auctionEndPrice); - - // if forceUpToSlippage is passed, use max slippage price as end price - if (forceUpToSlippage) { - auctionEndPrice = limitPrice; - constrainedBySlippage = false; - } else { - // use BEST (limit price, auction end price) as end price - auctionEndPrice = BN.min(limitPrice, auctionEndPrice); - } - - // apply additional buffer if provided - if (additionalEndPriceBuffer) { - auctionEndPrice = auctionEndPrice.add(additionalEndPriceBuffer); - constrainedBySlippage = limitPrice.lt(auctionEndPrice); - } - - // if ensureCrossingEndPrice is passed, ensure auction end price crosses bestAskPrice - if (ensureCrossingEndPrice && bestAskPrice) { - auctionEndPrice = BN.max( - auctionEndPrice, - bestAskPrice.add(auctionEndPriceBuffer) - ); - } - - auctionStartPrice = BN.min(auctionStartPrice, auctionEndPrice); - } else { - auctionStartPrice = startPriceFromSettings.add(auctionStartPriceBuffer); - - const worstPriceToUse = BN.min( - endPriceFromSettings, - startPriceFromSettings - ); // Handles edge cases like if the worst price on the book was better than the oracle price, and the settings are asking to be relative to the oracle price - - auctionEndPrice = PRICE_PRECISION.sub(auctionEndPriceBuffer) - .mul(worstPriceToUse) - .div(PRICE_PRECISION); - - constrainedBySlippage = limitPrice.gt(auctionEndPrice); - - // if forceUpToSlippage is passed, use max slippage price as end price - if (forceUpToSlippage) { - auctionEndPrice = limitPrice; - constrainedBySlippage = false; - } else { - // use BEST (limit price, auction end price) as end price - auctionEndPrice = BN.max(limitPrice, auctionEndPrice); - } - - // apply additional buffer if provided - if (additionalEndPriceBuffer) { - auctionEndPrice = auctionEndPrice.sub(additionalEndPriceBuffer); - constrainedBySlippage = limitPrice.gt(auctionEndPrice); - } - - // if ensureCrossingEndPrice is passed, ensure auction end price crosses bestBidPrice - if (ensureCrossingEndPrice && bestBidPrice) { - auctionEndPrice = BN.min( - auctionEndPrice, - bestBidPrice.sub(auctionEndPriceBuffer) - ); - } - - auctionStartPrice = BN.max(auctionStartPrice, auctionEndPrice); - } - - return { - auctionStartPrice, - auctionEndPrice, - auctionDuration: duration, - constrainedBySlippage, - }; -}; - -/** - * Helper function which derived market order params from the CORE data that is used to create them. - * @param param0 - * @returns - */ -const deriveMarketOrderParams = ({ - marketType, - marketIndex, - direction, - maxLeverageSelected, - maxLeverageOrderSize, - baseAmount, - reduceOnly, - allowInfSlippage, - oraclePrice, - bestPrice, - entryPrice, - worstPrice, - markPrice, - auctionDuration, - auctionStartPriceOffset, - auctionEndPriceOffset, - auctionStartPriceOffsetFrom, - auctionEndPriceOffsetFrom, - auctionPriceCaps, - slippageTolerance, - isOracleOrder, - additionalEndPriceBuffer, - forceUpToSlippage, - bestBidPrice, - bestAskPrice, - ensureCrossingEndPrice, -}: { - marketType: MarketType; - marketIndex: number; - direction: PositionDirection; - maxLeverageSelected: boolean; - maxLeverageOrderSize: BN; - baseAmount: BN; - reduceOnly: boolean; - allowInfSlippage: boolean; - oraclePrice: BN; - bestPrice: BN; - entryPrice: BN; - worstPrice: BN; - markPrice: BN; - auctionDuration: number; - auctionStartPriceOffset: number; - auctionEndPriceOffset: number; - auctionPriceCaps?: { - min: BN; - max: BN; - }; - auctionStartPriceOffsetFrom: TradeOffsetPrice; - auctionEndPriceOffsetFrom: TradeOffsetPrice; - slippageTolerance: number; - isOracleOrder?: boolean; - additionalEndPriceBuffer?: BN; - forceUpToSlippage?: boolean; - bestBidPrice?: BN; - bestAskPrice?: BN; - ensureCrossingEndPrice?: boolean; -}): OptionalOrderParams & { constrainedBySlippage?: boolean } => { - const priceObject = getPriceObject({ - oraclePrice, - markPrice, - bestOffer: bestPrice, - entryPrice, - worstPrice, - direction, - }); - - // max slippage price - let limitPrice = getMarketOrderLimitPrice({ - direction, - baselinePrice: priceObject[auctionStartPriceOffsetFrom], - slippageTolerance: allowInfSlippage ? undefined : slippageTolerance, - }); - - if (additionalEndPriceBuffer) { - limitPrice = isVariant(direction, 'long') - ? limitPrice.add(additionalEndPriceBuffer) - : limitPrice.sub(additionalEndPriceBuffer); - } - - const auctionParams = getMarketAuctionParams({ - direction, - startPriceFromSettings: priceObject[auctionStartPriceOffsetFrom], - endPriceFromSettings: priceObject[auctionEndPriceOffsetFrom], - limitPrice, - duration: auctionDuration, - auctionStartPriceOffset: auctionStartPriceOffset, - auctionEndPriceOffset: auctionEndPriceOffset, - additionalEndPriceBuffer, - forceUpToSlippage, - bestBidPrice, - bestAskPrice, - ensureCrossingEndPrice, - }); - - let orderParams = getMarketOrderParams({ - marketType, - marketIndex, - direction, - baseAssetAmount: maxLeverageSelected ? maxLeverageOrderSize : baseAmount, - reduceOnly, - price: allowInfSlippage ? undefined : limitPrice, - ...auctionParams, - }); - - if (isOracleOrder) { - // wont work if oracle is zero - if (!oraclePrice.eq(ZERO)) { - const oracleAuctionParams = deriveOracleAuctionParams({ - direction: direction, - oraclePrice, - auctionStartPrice: auctionParams.auctionStartPrice, - auctionEndPrice: auctionParams.auctionEndPrice, - limitPrice: auctionParams.auctionEndPrice, - auctionPriceCaps: auctionPriceCaps, - }); - - orderParams = { - ...orderParams, - ...oracleAuctionParams, - price: undefined, - orderType: OrderType.ORACLE, - }; - } - } - - return orderParams; -}; - -const getLimitAuctionParams = ({ - direction, - inputPrice, - startPriceFromSettings, - duration, - auctionStartPriceOffset, - oraclePriceBands, -}: { - direction: PositionDirection; - inputPrice: BigNum; - startPriceFromSettings: BN; - duration: number; - auctionStartPriceOffset: number; - oraclePriceBands?: [BN, BN]; -}): AuctionParams => { - let limitAuctionParams = EMPTY_AUCTION_PARAMS; - - const auctionStartPriceBuffer = inputPrice.scale( - Math.abs(auctionStartPriceOffset * 100), - 10000 - ).val; - - if ( - isVariant(direction, 'long') && - startPriceFromSettings && - startPriceFromSettings.lt(inputPrice.val) && - startPriceFromSettings.gt(ZERO) - ) { - limitAuctionParams = { - auctionStartPrice: startPriceFromSettings.sub(auctionStartPriceBuffer), - auctionEndPrice: inputPrice.val, - auctionDuration: duration, - }; - } else if ( - isVariant(direction, 'short') && - startPriceFromSettings && - startPriceFromSettings.gt(ZERO) && - startPriceFromSettings.gt(inputPrice.val) - ) { - limitAuctionParams = { - auctionStartPrice: startPriceFromSettings.add(auctionStartPriceBuffer), - auctionEndPrice: inputPrice.val, - auctionDuration: duration, - }; - } - - if (oraclePriceBands && limitAuctionParams.auctionDuration) { - const [minPrice, maxPrice] = oraclePriceBands; - - // start and end price cant be outside of the oracle price bands - limitAuctionParams.auctionStartPrice = BN.max( - BN.min(limitAuctionParams.auctionStartPrice, maxPrice), - minPrice - ); - - limitAuctionParams.auctionEndPrice = BN.max( - BN.min(limitAuctionParams.auctionEndPrice, maxPrice), - minPrice - ); - } - - return limitAuctionParams; -}; - -const getPriceObject = ({ - oraclePrice, - bestOffer, - entryPrice, - worstPrice, - markPrice, - direction, -}: { - oraclePrice: BN; - bestOffer: BN; - entryPrice: BN; - worstPrice: BN; - markPrice: BN; - direction: PositionDirection; -}) => { - let best: BN; - - const nonZeroOptions = [oraclePrice, bestOffer, markPrice].filter( - (price) => price !== undefined && price?.gt(ZERO) - ); - - if (nonZeroOptions.length === 0) { - // console.error('Unable to create valid auction params'); - return { - oracle: ZERO, - bestOffer: ZERO, - entry: ZERO, - best: ZERO, - worst: ZERO, - mark: ZERO, - }; - } - - if (isVariant(direction, 'long')) { - best = nonZeroOptions.reduce((a, b) => (a.lt(b) ? a : b)); // lowest price - } else { - best = nonZeroOptions.reduce((a, b) => (a.gt(b) ? a : b)); // highest price - } - - // if zero values come through, fallback to nonzero value - return { - oracle: oraclePrice?.gt(ZERO) ? oraclePrice : best, - bestOffer: bestOffer?.gt(ZERO) ? bestOffer : best, - entry: entryPrice, - best, - worst: worstPrice, - mark: markPrice?.gt(ZERO) ? markPrice : best, - }; -}; - -/* LP Utils */ -const getLpSharesAmountForQuote = ( - driftClient: DriftClient, - marketIndex: number, - quoteAmount: BN -): BigNum => { - const tenMillionBigNum = BigNum.fromPrint('10000000', QUOTE_PRECISION_EXP); - - const pricePerLpShare = BigNum.from( - driftClient.getQuoteValuePerLpShare(marketIndex), - QUOTE_PRECISION_EXP - ); - - return BigNum.from(quoteAmount, QUOTE_PRECISION_EXP) - .scale( - tenMillionBigNum.toNum(), - pricePerLpShare.mul(tenMillionBigNum).toNum() - ) - .shiftTo(AMM_RESERVE_PRECISION_EXP); -}; - -const getQuoteValueForLpShares = ( - driftClient: DriftClient, - marketIndex: number, - sharesAmount: BN -): BigNum => { - const pricePerLpShare = BigNum.from( - driftClient.getQuoteValuePerLpShare(marketIndex), - QUOTE_PRECISION_EXP - ).shiftTo(AMM_RESERVE_PRECISION_EXP); - const lpSharesBigNum = BigNum.from(sharesAmount, AMM_RESERVE_PRECISION_EXP); - return lpSharesBigNum.mul(pricePerLpShare).shiftTo(QUOTE_PRECISION_EXP); -}; - -const getTokenAddress = ( - mintAddress: PublicKey, - userPubKey: PublicKey -): Promise => { - return getAssociatedTokenAddress(mintAddress, userPubKey, true); -}; - -const getBalanceFromTokenAccountResult = (account: { - pubkey: PublicKey; - account: AccountInfo; -}) => { - return account?.account.data?.parsed?.info?.tokenAmount?.uiAmount; -}; - -const getTokenAccount = async ( - connection: Connection, - mintAddress: PublicKey, - userPubKey: PublicKey -): Promise<{ - tokenAccount: { - pubkey: PublicKey; - account: import('@solana/web3.js').AccountInfo< - import('@solana/web3.js').ParsedAccountData - >; - }; - tokenAccountWarning: boolean; -}> => { - const tokenAccounts = await connection.getParsedTokenAccountsByOwner( - userPubKey, - { mint: mintAddress } - ); - - const associatedAddress = await getAssociatedTokenAddress( - mintAddress, - userPubKey, - true - ); - - const targetAccount = - tokenAccounts.value.filter((account) => - account.pubkey.equals(associatedAddress) - )[0] || tokenAccounts.value[0]; - - const anotherBalanceExists = tokenAccounts.value.find((account) => { - return ( - !!getBalanceFromTokenAccountResult(account) && - !account.pubkey.equals(targetAccount.pubkey) - ); - }); - - let tokenAccountWarning = false; - - if (anotherBalanceExists) { - tokenAccountWarning = true; - } - - return { - tokenAccount: targetAccount, - tokenAccountWarning, - }; -}; - -const getMultipleAccounts = async ( - connection: any, - keys: string[], - commitment: string -) => { - const result = await Promise.all( - chunks(keys, 99).map((chunk) => - getMultipleAccountsCore(connection, chunk, commitment) - ) - ); - - const array = result - .map( - (a) => - a.array - .map((acc) => { - if (!acc) { - return undefined; - } - - const { data, ...rest } = acc; - const obj = { - ...rest, - data: Buffer.from(data[0], 'base64'), - } as AccountInfo; - return obj; - }) - .filter((_) => _) as AccountInfo[] - ) - .flat(); - return { keys, array }; -}; - -const getMultipleAccountsCore = async ( - connection: any, - keys: string[], - commitment: string -) => { - const args = connection._buildArgs([keys], commitment, 'base64'); - - const unsafeRes = await connection._rpcRequest('getMultipleAccounts', args); - if (unsafeRes.error) { - throw new Error( - 'failed to get info about account ' + unsafeRes.error.message - ); - } - - if (unsafeRes.result.value) { - const array = unsafeRes.result.value as AccountInfo[]; - return { keys, array }; - } - - // TODO: fix - throw new Error(); -}; - -const userExists = async ( - driftClient: DriftClient, - userId: number, - authority: PublicKey -) => { - let userAccountExists = false; - - try { - const user = driftClient.getUser(userId, authority); - userAccountExists = await user.exists(); - } catch (e) { - // user account does not exist so we leave userAccountExists false - } - - return userAccountExists; -}; - -function chunks(array: T[], size: number): T[][] { - return Array.apply(0, new Array(Math.ceil(array.length / size))).map( - (_, index) => array.slice(index * size, (index + 1) * size) - ); -} - -/** - * Trim trailing zeros from a numerical string - * @param str - numerical string to format - * @param zerosToShow - max number of zeros to show after the decimal. Similar to number.toFixed() but won't trim non-zero values. Optional, default value is 1 - */ -const trimTrailingZeros = (str: string, zerosToShow = 1) => { - // Ignore strings with no decimal point - if (!str.includes('.')) return str; - - const sides = str.split('.'); - - sides[1] = sides[1].replace(/0+$/, ''); - - if (sides[1].length < zerosToShow) { - const zerosToAdd = zerosToShow - sides[1].length; - sides[1] = `${sides[1]}${Array(zerosToAdd).fill('0').join('')}`; - } - - if (sides[1].length === 0) { - return sides[0]; - } else { - return sides.join('.'); - } -}; - -const formatTokenInputCurried = - (setAmount: (amount: string) => void, spotMarketConfig: SpotMarketConfig) => - (newAmount: string) => { - if (isNaN(+newAmount)) return; - - if (newAmount === '') { - setAmount(''); - return; - } - - const lastChar = newAmount[newAmount.length - 1]; - - // if last char of string is a decimal point, don't format - if (lastChar === '.') { - setAmount(newAmount); - return; - } - - if (lastChar === '0') { - // if last char of string is a zero in the decimal places, cut it off if it exceeds precision - const numOfDigitsAfterDecimal = newAmount.split('.')[1]?.length ?? 0; - if (numOfDigitsAfterDecimal > spotMarketConfig.precisionExp.toNumber()) { - setAmount(newAmount.slice(0, -1)); - } else { - setAmount(newAmount); - } - return; - } - - const formattedAmount = Number( - (+newAmount).toFixed(spotMarketConfig.precisionExp.toNumber()) - ); - setAmount(formattedAmount.toString()); - }; - -// --- Export The Utils - -export const COMMON_UI_UTILS = { - abbreviateAddress, - calculateAverageEntryPrice, - chunks, - compareSignatures, - createPlaceholderIWallet, - deriveMarketOrderParams, - fetchCurrentSubaccounts, - fetchUserClientsAndAccounts, - formatTokenInputCurried, - getBalanceFromTokenAccountResult, - getIdAndAuthorityFromKey, - getLimitAuctionParams, - getLpSharesAmountForQuote, - getMarketAuctionParams, - getMarketKey, - getMarketOrderLimitPrice, - getMultipleAccounts, - getMultipleAccountsCore, - getPriceObject, - getQuoteValueForLpShares, - getSignatureVerificationMessageForSettings, - getTokenAccount, - getTokenAddress, - getUserKey, - hashSignature, - initializeAndSubscribeToNewUserAccount, - userExists, - verifySignature, - trimTrailingZeros, - ...USER_UTILS, - ...TRADING_UTILS, - ...MARKET_UTILS, - ...ORDER_COMMON_UTILS, -}; diff --git a/common-ts/src/common-ui-utils/index.ts b/common-ts/src/common-ui-utils/index.ts deleted file mode 100644 index 27898f74..00000000 --- a/common-ts/src/common-ui-utils/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './commonUiUtils'; -export * from './market'; -export * from './order'; -export * from './trading'; -export * from './user'; -export * from './settings/settings'; diff --git a/common-ts/src/common-ui-utils/market.ts b/common-ts/src/common-ui-utils/market.ts deleted file mode 100644 index f9e5daaf..00000000 --- a/common-ts/src/common-ui-utils/market.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { - PerpMarketAccount, - PerpOperation, - SpotMarketAccount, - SpotOperation, - isOperationPaused, - InsuranceFundOperation, - MarketType, - SpotMarketConfig, - DriftEnv, - PerpMarketConfig, - PerpMarkets, - SpotMarkets, - DriftClient, -} from '@drift-labs/sdk'; -import { ENUM_UTILS } from '../utils'; -import { DEFAULT_MAX_MARKET_LEVERAGE } from '../constants/markets'; - -const getBaseAssetSymbol = (marketName: string, removePrefix = false) => { - let baseAssetSymbol = marketName.replace('-PERP', '').replace('/USDC', ''); - - if (removePrefix) { - baseAssetSymbol = baseAssetSymbol.replace('1K', '').replace('1M', ''); - } - - return baseAssetSymbol; -}; - -const PerpOperationsMap = { - UPDATE_FUNDING: 'Funding', - AMM_FILL: 'AMM Fills', - FILL: 'Fills', - SETTLE_PNL: 'Settle P&L', - SETTLE_PNL_WITH_POSITION: 'Settle P&L With Open Position', -}; - -const SpotOperationsMap = { - UPDATE_CUMULATIVE_INTEREST: 'Update Cumulative Interest', - FILL: 'Fills', - WITHDRAW: 'Withdrawals', -}; - -const InsuranceFundOperationsMap = { - INIT: 'Initialize IF', - ADD: 'Deposit To IF', - REQUEST_REMOVE: 'Request Withdrawal From IF', - REMOVE: 'Withdraw From IF', -}; - -const getPausedOperations = ( - marketAccount: PerpMarketAccount | SpotMarketAccount -): string[] => { - if (!marketAccount) return []; - - const pausedOperations = []; - //@ts-ignore - const isPerp = !!marketAccount.amm; - - // check perp operations - if (isPerp) { - Object.keys(PerpOperation) - .filter((operation) => - isOperationPaused( - marketAccount.pausedOperations, - PerpOperation[operation] - ) - ) - .forEach((pausedOperation) => { - pausedOperations.push(PerpOperationsMap[pausedOperation]); - }); - } else { - // check spot operations - Object.keys(SpotOperation) - .filter((operation) => - isOperationPaused( - marketAccount.pausedOperations, - SpotOperation[operation] - ) - ) - .forEach((pausedOperation) => { - pausedOperations.push(SpotOperationsMap[pausedOperation]); - }); - - // check IF operations - Object.keys(InsuranceFundOperation) - .filter((operation) => - isOperationPaused( - //@ts-ignore - marketAccount.ifPausedOperations, - InsuranceFundOperation[operation] - ) - ) - .forEach((pausedOperation) => { - pausedOperations.push(InsuranceFundOperationsMap[pausedOperation]); - }); - } - - return pausedOperations; -}; - -function getMarketConfig( - driftEnv: DriftEnv, - marketType: typeof MarketType.PERP, - marketIndex: number -): PerpMarketConfig; -function getMarketConfig( - driftEnv: DriftEnv, - marketType: typeof MarketType.SPOT, - marketIndex: number -): SpotMarketConfig; -function getMarketConfig( - driftEnv: DriftEnv, - marketType: MarketType, - marketIndex: number -): PerpMarketConfig | SpotMarketConfig { - const isPerp = ENUM_UTILS.match(marketType, MarketType.PERP); - - if (isPerp) { - return PerpMarkets[driftEnv][marketIndex]; - } else { - return SpotMarkets[driftEnv][marketIndex]; - } -} - -const getMaxLeverageForMarketAccount = ( - marketType: MarketType, - marketAccount: PerpMarketAccount | SpotMarketAccount -): { - maxLeverage: number; - highLeverageMaxLeverage: number; - hasHighLeverage: boolean; -} => { - const isPerp = ENUM_UTILS.match(marketType, MarketType.PERP); - - try { - if (isPerp) { - const perpMarketAccount = marketAccount as PerpMarketAccount; - - const maxLeverage = parseFloat( - ( - 1 / - ((perpMarketAccount?.marginRatioInitial - ? perpMarketAccount.marginRatioInitial - : 10000 / DEFAULT_MAX_MARKET_LEVERAGE) / - 10000) - ).toFixed(2) - ); - - const marketHasHighLeverageMode = - !!perpMarketAccount?.highLeverageMarginRatioInitial; - - const highLeverageMaxLeverage = marketHasHighLeverageMode - ? parseFloat( - ( - 1 / - ((perpMarketAccount?.highLeverageMarginRatioInitial - ? perpMarketAccount?.highLeverageMarginRatioInitial - : 10000 / DEFAULT_MAX_MARKET_LEVERAGE) / - 10000) - ).toFixed(1) - ) - : 0; - - return { - maxLeverage, - highLeverageMaxLeverage, - hasHighLeverage: marketHasHighLeverageMode, - }; - } else { - const spotMarketAccount = marketAccount as SpotMarketAccount; - - const liabilityWeight = spotMarketAccount - ? spotMarketAccount.initialLiabilityWeight / 10000 - : 0; - - return { - maxLeverage: parseFloat((1 / (liabilityWeight - 1)).toFixed(2)), - highLeverageMaxLeverage: 0, - hasHighLeverage: false, - }; - } - } catch (e) { - console.error(e); - return { - maxLeverage: 0, - highLeverageMaxLeverage: 0, - hasHighLeverage: false, - }; - } -}; - -const getMaxLeverageForMarket = ( - marketType: MarketType, - marketIndex: number, - driftClient: DriftClient -): { - maxLeverage: number; - highLeverageMaxLeverage: number; - hasHighLeverage: boolean; -} => { - const marketAccount = ENUM_UTILS.match(marketType, MarketType.PERP) - ? driftClient.getPerpMarketAccount(marketIndex) - : driftClient.getSpotMarketAccount(marketIndex); - - return getMaxLeverageForMarketAccount(marketType, marketAccount); -}; - -export const MARKET_UTILS = { - getBaseAssetSymbol, - getPausedOperations, - PerpOperationsMap, - SpotOperationsMap, - InsuranceFundOperationsMap, - getMarketConfig, - getMaxLeverageForMarket, - getMaxLeverageForMarketAccount, -}; diff --git a/common-ts/src/common-ui-utils/trading.ts b/common-ts/src/common-ui-utils/trading.ts deleted file mode 100644 index 0adeec61..00000000 --- a/common-ts/src/common-ui-utils/trading.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { - AMM_RESERVE_PRECISION, - BN, - BigNum, - DriftClient, - MARGIN_PRECISION, - MAX_LEVERAGE_ORDER_SIZE, - ONE, - PRICE_PRECISION, - PRICE_PRECISION_EXP, - PerpMarketAccount, - PositionDirection, - QUOTE_PRECISION_EXP, - SpotMarketAccount, - User, - ZERO, - isVariant, -} from '@drift-labs/sdk'; -import { MarketId, OpenPosition, UIOrderType } from 'src/types'; - -const calculatePnlPctFromPosition = ( - pnl: BN, - position: OpenPosition, - marginUsed?: BN -): number => { - if (!position?.quoteEntryAmount || position?.quoteEntryAmount.eq(ZERO)) - return 0; - - let marginUsedNum: number; - - if (marginUsed) { - marginUsedNum = BigNum.from(marginUsed, QUOTE_PRECISION_EXP).toNum(); - } else { - const leverage = convertMarginRatioToLeverage(position.maxMarginRatio) ?? 1; - const quoteEntryAmountNum = BigNum.from( - position.quoteEntryAmount.abs(), - QUOTE_PRECISION_EXP - ).toNum(); - - if (leverage <= 0 || quoteEntryAmountNum <= 0) { - marginUsedNum = 0; - } else { - marginUsedNum = quoteEntryAmountNum / leverage; - } - } - - if (marginUsedNum <= 0) { - return 0; - } - - return ( - BigNum.from(pnl, QUOTE_PRECISION_EXP) - .shift(5) - .div(BigNum.fromPrint(`${marginUsedNum}`, QUOTE_PRECISION_EXP)) - .toNum() * 100 - ); -}; - -const POTENTIAL_PROFIT_DEFAULT_STATE = { - estimatedProfit: BigNum.zero(PRICE_PRECISION_EXP), - estimatedProfitBeforeFees: BigNum.zero(PRICE_PRECISION_EXP), - estimatedTakerFee: BigNum.zero(PRICE_PRECISION_EXP), - notionalSizeAtEntry: BigNum.zero(PRICE_PRECISION_EXP), - notionalSizeAtExit: BigNum.zero(PRICE_PRECISION_EXP), -}; - -const calculatePotentialProfit = (props: { - currentPositionSize: BigNum; - currentPositionDirection: PositionDirection; - currentPositionEntryPrice: BigNum; - tradeDirection: PositionDirection; - /** - * Amount of position being closed in base asset size - */ - exitBaseSize: BigNum; - /** - * Either the user's limit price (for limit orders) or the estimated exit price (for market orders) - */ - exitPrice: BigNum; - takerFeeBps: number; - slippageTolerance?: number; - isMarketOrder?: boolean; -}): { - estimatedProfit: BigNum; - estimatedProfitBeforeFees: BigNum; - estimatedTakerFee: BigNum; - notionalSizeAtEntry: BigNum; - notionalSizeAtExit: BigNum; -} => { - let estimatedProfit = BigNum.zero(PRICE_PRECISION_EXP); - let estimatedProfitBeforeFees = BigNum.zero(PRICE_PRECISION_EXP); - let estimatedTakerFee = BigNum.zero(PRICE_PRECISION_EXP); - let notionalSizeAtEntry = BigNum.zero(PRICE_PRECISION_EXP); - let notionalSizeAtExit = BigNum.zero(PRICE_PRECISION_EXP); - - const isClosingLong = - isVariant(props.currentPositionDirection, 'long') && - isVariant(props.tradeDirection, 'short'); - const isClosingShort = - isVariant(props.currentPositionDirection, 'short') && - isVariant(props.tradeDirection, 'long'); - - if (!isClosingLong && !isClosingShort) return POTENTIAL_PROFIT_DEFAULT_STATE; - if (!props.exitBaseSize) return POTENTIAL_PROFIT_DEFAULT_STATE; - - if ( - props.exitBaseSize.eqZero() || - props.currentPositionSize.lt(props.exitBaseSize) - ) { - return POTENTIAL_PROFIT_DEFAULT_STATE; - } - - const baseSizeBeingClosed = props.exitBaseSize.lte(props.currentPositionSize) - ? props.exitBaseSize - : props.currentPositionSize; - - // Notional size of amount being closed at entry and exit - notionalSizeAtEntry = baseSizeBeingClosed.mul( - props.currentPositionEntryPrice.shiftTo(baseSizeBeingClosed.precision) - ); - notionalSizeAtExit = baseSizeBeingClosed.mul( - props.exitPrice.shiftTo(baseSizeBeingClosed.precision) - ); - - if (isClosingLong) { - estimatedProfitBeforeFees = notionalSizeAtExit.sub(notionalSizeAtEntry); - } else if (isClosingShort) { - estimatedProfitBeforeFees = notionalSizeAtEntry.sub(notionalSizeAtExit); - } - - // subtract takerFee if applicable - if (props.takerFeeBps > 0) { - const takerFeeDenominator = Math.floor(100 / (props.takerFeeBps * 0.01)); - estimatedTakerFee = notionalSizeAtExit.scale(1, takerFeeDenominator); - estimatedProfit = estimatedProfitBeforeFees.sub( - estimatedTakerFee.shiftTo(estimatedProfitBeforeFees.precision) - ); - } else { - estimatedProfit = estimatedProfitBeforeFees; - } - - return { - estimatedProfit, - estimatedProfitBeforeFees, - estimatedTakerFee, - notionalSizeAtEntry, - notionalSizeAtExit, - }; -}; - -/** - * Check if the order type is a market order or oracle market order - */ -const checkIsMarketOrderType = (orderType: UIOrderType) => { - return orderType === 'market' || orderType === 'oracle'; -}; - -/** - * Calculate the liquidation price of a position after a trade. Requires DriftClient to be subscribed. - * If the order type is limit order, a limit price must be provided. - */ -const calculateLiquidationPriceAfterPerpTrade = ({ - estEntryPrice, - orderType, - perpMarketIndex, - tradeBaseSize, - isLong, - userClient, - oraclePrice, - limitPrice, - offsetCollateral, - precision = 2, - isEnteringHighLeverageMode, - capLiqPrice, - marginType, -}: { - estEntryPrice: BN; - orderType: UIOrderType; - perpMarketIndex: number; - tradeBaseSize: BN; - isLong: boolean; - userClient: User; - oraclePrice: BN; - limitPrice?: BN; - offsetCollateral?: BN; - precision?: number; - isEnteringHighLeverageMode?: boolean; - capLiqPrice?: boolean; - marginType?: 'Cross' | 'Isolated'; -}) => { - const ALLOWED_ORDER_TYPES: UIOrderType[] = [ - 'limit', - 'market', - 'oracle', - 'stopMarket', - 'stopLimit', - 'oracleLimit', - ]; - - if (!ALLOWED_ORDER_TYPES.includes(orderType)) { - console.error( - 'Invalid order type for perp trade liquidation price calculation', - orderType - ); - return 0; - } - - if (orderType === 'limit' && !limitPrice) { - console.error( - 'Limit order must have a limit price for perp trade liquidation price calculation' - ); - return 0; - } - - const signedBaseSize = isLong ? tradeBaseSize : tradeBaseSize.neg(); - const priceToUse = [ - 'limit', - 'stopMarket', - 'stopLimit', - 'oracleLimit', - ].includes(orderType) - ? limitPrice - : estEntryPrice; - - const liqPriceBn = userClient.liquidationPrice( - perpMarketIndex, - signedBaseSize, - priceToUse, - undefined, - undefined, // we can exclude open orders since open orders will be cancelled first (which results in reducing account leverage) before actual liquidation - offsetCollateral, - isEnteringHighLeverageMode, - marginType === 'Isolated' ? 'Isolated' : undefined - ); - - if (liqPriceBn.isNeg()) { - // means no liquidation price - return 0; - } - - // Check if user has a spot position using the same oracle as the perp market - // If so, force capLiqPrice to be false to avoid incorrect price capping - // Technically in this case, liq price could be lower for a short or higher for a long - const perpMarketOracle = - userClient.driftClient.getPerpMarketAccount(perpMarketIndex)?.amm?.oracle; - - const spotMarketWithSameOracle = userClient.driftClient - .getSpotMarketAccounts() - .find((market) => market.oracle.equals(perpMarketOracle)); - - let hasSpotPositionWithSameOracle = false; - if (spotMarketWithSameOracle) { - const spotPosition = userClient.getSpotPosition( - spotMarketWithSameOracle.marketIndex - ); - hasSpotPositionWithSameOracle = !!spotPosition; - } - - const effectiveCapLiqPrice = hasSpotPositionWithSameOracle - ? false - : capLiqPrice; - - const cappedLiqPriceBn = effectiveCapLiqPrice - ? isLong - ? BN.min(liqPriceBn, oraclePrice) - : BN.max(liqPriceBn, oraclePrice) - : liqPriceBn; - - const liqPriceBigNum = BigNum.from(cappedLiqPriceBn, PRICE_PRECISION_EXP); - - const liqPriceNum = - Math.round(liqPriceBigNum.toNum() * 10 ** precision) / 10 ** precision; - - return liqPriceNum; -}; - -const convertLeverageToMarginRatio = (leverage: number): number | undefined => { - if (!leverage) return undefined; - return Math.round((1 / leverage) * MARGIN_PRECISION.toNumber()); -}; - -const convertMarginRatioToLeverage = ( - marginRatio: number, - decimals?: number -): number | undefined => { - if (!marginRatio) return undefined; - - const leverage = 1 / (marginRatio / MARGIN_PRECISION.toNumber()); - - return decimals - ? parseFloat(leverage.toFixed(decimals)) - : Math.round(leverage); -}; - -const getMarketTickSize = ( - driftClient: DriftClient, - marketId: MarketId -): BN => { - const marketAccount = marketId.isPerp - ? driftClient.getPerpMarketAccount(marketId.marketIndex) - : driftClient.getSpotMarketAccount(marketId.marketIndex); - if (!marketAccount) return ZERO; - - if (marketId.isPerp) { - return (marketAccount as PerpMarketAccount).amm.orderTickSize; - } else { - return (marketAccount as SpotMarketAccount).orderTickSize; - } -}; - -const getMarketTickSizeDecimals = ( - driftClient: DriftClient, - marketId: MarketId -) => { - const tickSize = getMarketTickSize(driftClient, marketId); - - const decimalPlaces = Math.max( - 0, - Math.floor( - Math.log10( - PRICE_PRECISION.div(tickSize.eq(ZERO) ? ONE : tickSize).toNumber() - ) - ) - ); - - return decimalPlaces; -}; - -const getMarketStepSize = (driftClient: DriftClient, marketId: MarketId) => { - const marketAccount = marketId.isPerp - ? driftClient.getPerpMarketAccount(marketId.marketIndex) - : driftClient.getSpotMarketAccount(marketId.marketIndex); - if (!marketAccount) return ZERO; - - if (marketId.isPerp) { - return (marketAccount as PerpMarketAccount).amm.orderStepSize; - } else { - return (marketAccount as SpotMarketAccount).orderStepSize; - } -}; - -const getMarketStepSizeDecimals = ( - driftClient: DriftClient, - marketId: MarketId -) => { - const stepSize = getMarketStepSize(driftClient, marketId); - - const decimalPlaces = Math.max( - 0, - Math.floor( - Math.log10( - AMM_RESERVE_PRECISION.div(stepSize.eq(ZERO) ? ONE : stepSize).toNumber() - ) - ) - ); - - return decimalPlaces; -}; - -/** - * Checks if a given order amount represents an entire position order - * by comparing it with MAX_LEVERAGE_ORDER_SIZE - * @param orderAmount - The BigNum order amount to check - * @returns true if the order is for the entire position, false otherwise - */ -export const isEntirePositionOrder = (orderAmount: BigNum): boolean => { - const maxLeverageSize = new BigNum( - MAX_LEVERAGE_ORDER_SIZE, - orderAmount.precision - ); - - const isMaxLeverage = Math.abs(maxLeverageSize.sub(orderAmount).toNum()) < 1; - - // Some order paths produce a truncated u64::MAX instead of MAX_LEVERAGE_ORDER_SIZE - const ALTERNATIVE_MAX_ORDER_SIZE = '18446744072000000000'; - const alternativeMaxSize = new BigNum( - ALTERNATIVE_MAX_ORDER_SIZE, - orderAmount.precision - ); - const isAlternativeMax = - Math.abs(alternativeMaxSize.sub(orderAmount).toNum()) < 1; - - return isMaxLeverage || isAlternativeMax; -}; - -/** - * Gets the MAX_LEVERAGE_ORDER_SIZE as a BigNum with the same precision as the given amount - * @param orderAmount - The BigNum order amount to match precision with - * @returns BigNum representation of MAX_LEVERAGE_ORDER_SIZE - */ -export const getMaxLeverageOrderSize = (orderAmount: BigNum): BigNum => { - return new BigNum(MAX_LEVERAGE_ORDER_SIZE, orderAmount.precision); -}; - -/** - * Formats an order size for display, showing "Entire Position" if it's a max leverage order - * @param orderAmount - The BigNum order amount to format - * @param formatFn - Optional custom format function, defaults to prettyPrint() - * @returns Formatted string showing either "Entire Position" or the formatted amount - */ -export const formatOrderSize = ( - orderAmount: BigNum, - formatFn?: (amount: BigNum) => string -): string => { - if (isEntirePositionOrder(orderAmount)) { - return 'Entire Position'; - } - return formatFn ? formatFn(orderAmount) : orderAmount.prettyPrint(); -}; - -/** - * Calculate the margin used for a specific perp position - * Returns the minimum of user's total collateral or the position's weighted value - */ -const getMarginUsedForPosition = ( - user: User, - marketIndex: number, - includeOpenOrders = true -): BN | undefined => { - const perpPosition = user.getPerpPosition(marketIndex); - if (!perpPosition) return undefined; - - const hc = user.getPerpPositionHealth({ - marginCategory: 'Initial', - perpPosition, - includeOpenOrders, - }); - const userCollateral = user.getTotalCollateral(); - return userCollateral.lt(hc.weightedValue) - ? userCollateral - : hc.weightedValue; -}; - -/** - * Validate if a leverage change would exceed the user's free collateral - * Returns true if the change is valid (doesn't exceed free collateral), false otherwise - */ -const validateLeverageChange = ({ - user, - marketIndex, - newLeverage, -}: { - user: User; - marketIndex: number; - newLeverage: number; -}): boolean => { - try { - // Convert leverage to margin ratio - const newMarginRatio = convertLeverageToMarginRatio(newLeverage); - if (!newMarginRatio) return true; - - // Get the perp position from the user - const perpPosition = user.getPerpPosition(marketIndex); - if (!perpPosition) return true; - - // Get current position weighted value - const currentPositionWeightedValue = user.getPerpPositionHealth({ - marginCategory: 'Initial', - perpPosition, - }).weightedValue; - - // Create a modified version of the position with new maxMarginRatio - const modifiedPosition = { - ...perpPosition, - maxMarginRatio: newMarginRatio, - }; - - // Calculate new weighted value with the modified position - const newPositionWeightedValue = user.getPerpPositionHealth({ - marginCategory: 'Initial', - perpPosition: modifiedPosition, - }).weightedValue; - - const perpPositionWeightedValueDelta = newPositionWeightedValue.sub( - currentPositionWeightedValue - ); - - const freeCollateral = user.getFreeCollateral(); - - // Check if weighted value delta exceeds free collateral - return perpPositionWeightedValueDelta.lte(freeCollateral); - } catch (error) { - console.warn('Error validating leverage change:', error); - return true; // Allow change if validation fails - } -}; - -export const TRADING_UTILS = { - calculatePnlPctFromPosition, - calculatePotentialProfit, - calculateLiquidationPriceAfterPerpTrade, - checkIsMarketOrderType, - convertLeverageToMarginRatio, - convertMarginRatioToLeverage, - getMarketTickSize, - getMarketTickSizeDecimals, - getMarketStepSize, - getMarketStepSizeDecimals, - isEntirePositionOrder, - getMaxLeverageOrderSize, - formatOrderSize, - getMarginUsedForPosition, - validateLeverageChange, -}; diff --git a/common-ts/src/drift/Drift/clients/AuthorityDrift/DriftOperations/index.ts b/common-ts/src/drift/Drift/clients/AuthorityDrift/DriftOperations/index.ts index a959c08e..9ee0fe95 100644 --- a/common-ts/src/drift/Drift/clients/AuthorityDrift/DriftOperations/index.ts +++ b/common-ts/src/drift/Drift/clients/AuthorityDrift/DriftOperations/index.ts @@ -13,9 +13,9 @@ import { ZERO, } from '@drift-labs/sdk'; import { TransactionSignature } from '@solana/web3.js'; -import { MARKET_UTILS } from '../../../../../common-ui-utils/market'; +import { MARKET_UTILS } from '../../../../../_deprecated/market-utils'; import { MAIN_POOL_ID } from '../../../../../constants'; -import { TRADING_UTILS } from '../../../../../common-ui-utils/trading'; +import { TRADING_UTILS } from '../../../../../_deprecated/trading-utils'; import { UserAccountCache } from '../../../stores/UserAccountCache'; import { createDepositTxn } from '../../../../base/actions/spot/deposit'; import { createUserAndDepositCollateralBaseTxn } from '../../../../base/actions/user/create'; diff --git a/common-ts/src/drift/Drift/clients/AuthorityDrift/index.ts b/common-ts/src/drift/Drift/clients/AuthorityDrift/index.ts index 723edd06..e52d93c8 100644 --- a/common-ts/src/drift/Drift/clients/AuthorityDrift/index.ts +++ b/common-ts/src/drift/Drift/clients/AuthorityDrift/index.ts @@ -20,7 +20,7 @@ import { TxParams, } from '@drift-labs/sdk'; import { Connection, PublicKey, TransactionSignature } from '@solana/web3.js'; -import { COMMON_UI_UTILS } from '../../../../common-ui-utils/commonUiUtils'; +import { COMMON_UI_UTILS } from '../../../../_deprecated/common-ui-utils'; import { DEFAULT_ACCOUNT_LOADER_COMMITMENT, DEFAULT_ACCOUNT_LOADER_POLLING_FREQUENCY_MS, @@ -31,7 +31,7 @@ import { PollingCategory, } from '../../constants'; import { MarketId } from '../../../../types'; -import { MARKET_UTILS } from '../../../../common-ui-utils/market'; +import { MARKET_UTILS } from '../../../../_deprecated/market-utils'; import { POLLING_DEPTHS, POLLING_INTERVALS, diff --git a/common-ts/src/drift/Drift/clients/CentralServerDrift/index.ts b/common-ts/src/drift/Drift/clients/CentralServerDrift/index.ts index 8be80249..964d4aa4 100644 --- a/common-ts/src/drift/Drift/clients/CentralServerDrift/index.ts +++ b/common-ts/src/drift/Drift/clients/CentralServerDrift/index.ts @@ -39,7 +39,7 @@ import { TransactionInstruction, VersionedTransaction, } from '@solana/web3.js'; -import { COMMON_UI_UTILS } from '../../../../common-ui-utils/commonUiUtils'; +import { COMMON_UI_UTILS } from '../../../../_deprecated/common-ui-utils'; import { DEFAULT_ACCOUNT_LOADER_COMMITMENT, DEFAULT_ACCOUNT_LOADER_POLLING_FREQUENCY_MS, diff --git a/common-ts/src/drift/base/actions/trade/editOrder.ts b/common-ts/src/drift/base/actions/trade/editOrder.ts index b507d819..5acd9e17 100644 --- a/common-ts/src/drift/base/actions/trade/editOrder.ts +++ b/common-ts/src/drift/base/actions/trade/editOrder.ts @@ -21,7 +21,7 @@ import { LimitOrderParamsOrderConfig, } from './openPerpOrder/types'; import { WithTxnParams } from '../../types'; -import { HighLeverageOptions } from '../../../../common-ui-utils/order'; +import { HighLeverageOptions } from '../../../../utils/orders'; /** * Parameters for editing an existing order diff --git a/common-ts/src/drift/base/actions/trade/margin.ts b/common-ts/src/drift/base/actions/trade/margin.ts index a3681d45..f6533df8 100644 --- a/common-ts/src/drift/base/actions/trade/margin.ts +++ b/common-ts/src/drift/base/actions/trade/margin.ts @@ -11,8 +11,8 @@ import { VersionedTransaction, } from '@solana/web3.js'; import { WithTxnParams } from '../../types'; -import { TRADING_UTILS } from '../../../../common-ui-utils/trading'; -import { MARKET_UTILS } from '../../../../common-ui-utils'; +import { TRADING_UTILS } from '../../../../_deprecated/trading-utils'; +import { MARKET_UTILS } from '../../../../_deprecated/market-utils'; export interface CreateUpdateMarketMaxLeverageIxsParams { driftClient: DriftClient; diff --git a/common-ts/src/drift/base/actions/trade/openPerpOrder/auction.ts b/common-ts/src/drift/base/actions/trade/openPerpOrder/auction.ts index 75e7a0ba..38f9b5d9 100644 --- a/common-ts/src/drift/base/actions/trade/openPerpOrder/auction.ts +++ b/common-ts/src/drift/base/actions/trade/openPerpOrder/auction.ts @@ -11,11 +11,9 @@ import { User, PositionDirection, } from '@drift-labs/sdk'; -import { - ORDER_COMMON_UTILS, - COMMON_UI_UTILS, - HighLeverageOptions, -} from '../../../../../common-ui-utils'; +import { ORDER_COMMON_UTILS } from '../../../../../_deprecated/order-utils'; +import { COMMON_UI_UTILS } from '../../../../../_deprecated/common-ui-utils'; +import { HighLeverageOptions } from '../../../../../utils/orders'; import { DEFAULT_LIMIT_AUCTION_DURATION } from '../../../constants/auction'; import { ENUM_UTILS } from '../../../../../utils'; import invariant from 'tiny-invariant'; diff --git a/common-ts/src/drift/base/actions/trade/openPerpOrder/dlobServer/index.ts b/common-ts/src/drift/base/actions/trade/openPerpOrder/dlobServer/index.ts index f5abc927..9fde422f 100644 --- a/common-ts/src/drift/base/actions/trade/openPerpOrder/dlobServer/index.ts +++ b/common-ts/src/drift/base/actions/trade/openPerpOrder/dlobServer/index.ts @@ -19,7 +19,7 @@ import { MappedAuctionParams, AuctionParamsFetchedCallback, } from '../../../../../utils/auctionParamsResponseMapper'; -import { encodeQueryParams } from '../../../../../../utils/fetch'; +import { encodeQueryParams } from '../../../../../../utils/core/fetch'; import { MarketId, TradeOffsetPrice } from '../../../../../../types'; import { convertToL2OrderBook, @@ -33,7 +33,7 @@ import { } from '../../../../../../utils/orderbook/types'; import { PollingSequenceGuard } from '../../../../../../utils/pollingSequenceGuard'; import { calculatePriceImpactFromL2 } from '../../../../../../utils/priceImpact'; -import { COMMON_UI_UTILS } from '../../../../../../common-ui-utils'; +import { COMMON_UI_UTILS } from '../../../../../../_deprecated/common-ui-utils'; import invariant from 'tiny-invariant'; export interface OptionalAuctionParamsRequestInputs { diff --git a/common-ts/src/drift/base/actions/trade/openPerpOrder/isolatedPositionDeposit.ts b/common-ts/src/drift/base/actions/trade/openPerpOrder/isolatedPositionDeposit.ts index 8dcbef16..580a2b93 100644 --- a/common-ts/src/drift/base/actions/trade/openPerpOrder/isolatedPositionDeposit.ts +++ b/common-ts/src/drift/base/actions/trade/openPerpOrder/isolatedPositionDeposit.ts @@ -10,7 +10,7 @@ import { ZERO, } from '@drift-labs/sdk'; import { PublicKey, TransactionInstruction } from '@solana/web3.js'; -import { TRADING_UTILS } from '../../../../../common-ui-utils/trading'; +import { TRADING_UTILS } from '../../../../../_deprecated/trading-utils'; import { AdditionalIsolatedPositionDeposit, IsolatedPositionDepositsOverride, diff --git a/common-ts/src/drift/base/actions/trade/openPerpOrder/openPerpMarketOrder/index.ts b/common-ts/src/drift/base/actions/trade/openPerpOrder/openPerpMarketOrder/index.ts index 364450e0..eb0e5af5 100644 --- a/common-ts/src/drift/base/actions/trade/openPerpOrder/openPerpMarketOrder/index.ts +++ b/common-ts/src/drift/base/actions/trade/openPerpOrder/openPerpMarketOrder/index.ts @@ -28,10 +28,8 @@ import { fetchTopMakers, OptionalAuctionParamsRequestInputs, } from '../dlobServer'; -import { - HighLeverageOptions, - ORDER_COMMON_UTILS, -} from '../../../../../../common-ui-utils/order'; +import { ORDER_COMMON_UTILS } from '../../../../../../_deprecated/order-utils'; +import { HighLeverageOptions } from '../../../../../../utils/orders'; import { WithTxnParams } from '../../../../types'; import { TxnOrSwiftResult, IsolatedPositionDepositsOverride } from '../types'; import { NoTopMakersError } from '../../../../../Drift/constants/errors'; diff --git a/common-ts/src/drift/base/actions/trade/openPerpOrder/openPerpNonMarketOrder/index.ts b/common-ts/src/drift/base/actions/trade/openPerpOrder/openPerpNonMarketOrder/index.ts index 94471c69..04378648 100644 --- a/common-ts/src/drift/base/actions/trade/openPerpOrder/openPerpNonMarketOrder/index.ts +++ b/common-ts/src/drift/base/actions/trade/openPerpOrder/openPerpNonMarketOrder/index.ts @@ -26,10 +26,8 @@ import { resolveBaseAssetAmount, } from '../../../../../utils/orderParams'; import { ENUM_UTILS } from '../../../../../../utils'; -import { - HighLeverageOptions, - ORDER_COMMON_UTILS, -} from '../../../../../../common-ui-utils'; +import { ORDER_COMMON_UTILS } from '../../../../../../_deprecated/order-utils'; +import { HighLeverageOptions } from '../../../../../../utils/orders'; import { createPlaceAndTakePerpMarketOrderIx } from '../openPerpMarketOrder'; import { TxnOrSwiftResult, diff --git a/common-ts/src/drift/base/actions/trade/openPerpOrder/openSwiftOrder/index.ts b/common-ts/src/drift/base/actions/trade/openPerpOrder/openSwiftOrder/index.ts index f86d1d6d..c4302425 100644 --- a/common-ts/src/drift/base/actions/trade/openPerpOrder/openSwiftOrder/index.ts +++ b/common-ts/src/drift/base/actions/trade/openPerpOrder/openSwiftOrder/index.ts @@ -13,10 +13,8 @@ import { OptionalOrderParams, PublicKey, } from '@drift-labs/sdk'; -import { - ENUM_UTILS, - getSwiftConfirmationTimeoutMs, -} from '../../../../../../utils'; +import { ENUM_UTILS } from '../../../../../../utils'; +import { getSwiftConfirmationTimeoutMs } from '../../../../../../utils/signedMsgs'; import { SwiftClient, SwiftOrderConfirmedEvent, @@ -28,7 +26,7 @@ import { import { MarketId } from '../../../../../../types'; import { Observable, Subscription } from 'rxjs'; import { OptionalTriggerOrderParams } from '../types'; -import { TRADING_UTILS } from '../../../../../../common-ui-utils/trading'; +import { TRADING_UTILS } from '../../../../../../_deprecated/trading-utils'; import { Connection } from '@solana/web3.js'; /** diff --git a/common-ts/src/drift/base/actions/trade/openPerpOrder/positionMaxLeverage.ts b/common-ts/src/drift/base/actions/trade/openPerpOrder/positionMaxLeverage.ts index 8c0b671b..0aaef0aa 100644 --- a/common-ts/src/drift/base/actions/trade/openPerpOrder/positionMaxLeverage.ts +++ b/common-ts/src/drift/base/actions/trade/openPerpOrder/positionMaxLeverage.ts @@ -1,6 +1,6 @@ import { DriftClient, User } from '@drift-labs/sdk'; import { PublicKey, TransactionInstruction } from '@solana/web3.js'; -import { TRADING_UTILS } from '../../../../../common-ui-utils/trading'; +import { TRADING_UTILS } from '../../../../../_deprecated/trading-utils'; /** * Helper function to determine if leverage needs updating and create the instruction if needed. diff --git a/common-ts/src/drift/base/actions/user/create.ts b/common-ts/src/drift/base/actions/user/create.ts index 8937fc09..42ffe263 100644 --- a/common-ts/src/drift/base/actions/user/create.ts +++ b/common-ts/src/drift/base/actions/user/create.ts @@ -18,7 +18,7 @@ import { TransactionInstruction, VersionedTransaction, } from '@solana/web3.js'; -import { USER_UTILS } from '../../../../common-ui-utils/user'; +import { USER_UTILS } from '../../../../_deprecated/user-utils'; interface CreateUserAndDepositCollateralBaseIxsParams { driftClient: DriftClient; diff --git a/common-ts/src/drift/base/details/user/balances.ts b/common-ts/src/drift/base/details/user/balances.ts index eed8c408..1f9e6cfa 100644 --- a/common-ts/src/drift/base/details/user/balances.ts +++ b/common-ts/src/drift/base/details/user/balances.ts @@ -7,7 +7,7 @@ import { QUOTE_PRECISION_EXP, User, } from '@drift-labs/sdk'; -import { MARKET_UTILS } from '../../../../common-ui-utils/market'; +import { MARKET_UTILS } from '../../../../_deprecated/market-utils'; /** * Essential balance information for a spot market position. diff --git a/common-ts/src/drift/base/details/user/positions.ts b/common-ts/src/drift/base/details/user/positions.ts index f4850596..d2765324 100644 --- a/common-ts/src/drift/base/details/user/positions.ts +++ b/common-ts/src/drift/base/details/user/positions.ts @@ -19,7 +19,7 @@ import { calculatePositionPNL, calculateUnsettledFundingPnl, } from '@drift-labs/sdk'; -import { TRADING_UTILS } from '../../../../common-ui-utils/trading'; +import { TRADING_UTILS } from '../../../../_deprecated/trading-utils'; import { ENUM_UTILS } from '../../../../utils'; import { MAX_PREDICTION_PRICE_BIG_NUM, diff --git a/common-ts/src/index.ts b/common-ts/src/index.ts index 5b4c141a..23d31252 100644 --- a/common-ts/src/index.ts +++ b/common-ts/src/index.ts @@ -1,49 +1,56 @@ -export * from './Config'; -export * from './chartConstants'; -export * from './types'; -export * from './EnvironmentConstants'; +// Domain modules (via utils barrel) export * from './utils'; -export * from './utils/index'; -export * from './utils/s3Buckets'; -export * from './serializableTypes'; -export * from './utils/candles/Candle'; + +// Non-domain utils that stay at utils/ level +export * from './utils/logger'; export * from './utils/featureFlags'; -export * from './utils/WalletConnectionState'; +export * from './utils/candles/Candle'; export * from './utils/rpcLatency'; -export * from './utils/token'; -export * from './utils/math'; -export * from './utils/logger'; -export * from './utils/equalityChecks'; -export * from './common-ui-utils'; -export * from './constants'; -export * from './actions/actionHelpers/actionHelpers'; export * from './utils/SharedInterval'; export * from './utils/Stopwatch'; export * from './utils/priority-fees'; export * from './utils/superstake'; -export * from './utils/fetch'; export * from './utils/priceImpact'; export * from './utils/dlob-server/DlobServerWebsocketUtils'; -export * from './common-ui-utils/settings/settings'; -export * from './utils/priority-fees'; export * from './utils/orderbook'; -export * from './clients/candleClient'; -export * from './clients/marketDataFeed'; -export * from './clients/swiftClient'; -export * from './clients/tvFeed'; -export * from './clients/DlobWebsocketClient'; export * from './utils/pollingSequenceGuard'; export * from './utils/driftEvents'; -export * from './utils/MultiplexWebSocket'; export * from './utils/SlotBasedResultValidator'; export * from './utils/CircularBuffers'; export * from './utils/rxjs'; export * from './utils/priorityFees'; export * from './utils/NumLib'; -export * from './utils/strings'; -export * from './utils/validation'; +export * from './utils/s3Buckets'; +export * from './utils/insuranceFund'; +export * from './utils/settings/settings'; export { default as millify } from './utils/millify'; -export * from './utils/markets/precisions'; +export { getSwiftConfirmationTimeoutMs } from './utils/signedMsgs'; +export { ResultSlotIncrementer } from './utils/ResultSlotIncrementer'; +export { MultiplexWebSocket } from './utils/MultiplexWebSocket'; + +// Deprecation facades +export { COMMON_UTILS } from './_deprecated/utils'; +export { COMMON_UI_UTILS } from './_deprecated/common-ui-utils'; +export { COMMON_MATH } from './_deprecated/common-math'; +export { EQUALITY_CHECKS } from './_deprecated/equality-checks'; +export { TRADING_UTILS } from './_deprecated/trading-utils'; +export { MARKET_UTILS } from './_deprecated/market-utils'; +export { ORDER_COMMON_UTILS } from './_deprecated/order-utils'; +export { USER_UTILS } from './_deprecated/user-utils'; + +// Non-utils modules +export * from './Config'; +export * from './chartConstants'; +export * from './types'; +export * from './EnvironmentConstants'; +export * from './serializableTypes'; +export * from './constants'; +export * from './actions/actionHelpers/actionHelpers'; +export * from './clients/candleClient'; +export * from './clients/marketDataFeed'; +export * from './clients/swiftClient'; +export * from './clients/tvFeed'; +export * from './clients/DlobWebsocketClient'; export * from './drift'; // External Program Errors diff --git a/common-ts/src/utils/accounts/index.ts b/common-ts/src/utils/accounts/index.ts new file mode 100644 index 00000000..caa0f286 --- /dev/null +++ b/common-ts/src/utils/accounts/index.ts @@ -0,0 +1,6 @@ +export * from './init'; +export * from './keys'; +export * from './subaccounts'; +export * from './wallet'; +export * from './signature'; +export * from './multiple'; diff --git a/common-ts/src/utils/accounts/init.ts b/common-ts/src/utils/accounts/init.ts new file mode 100644 index 00000000..dee49cf7 --- /dev/null +++ b/common-ts/src/utils/accounts/init.ts @@ -0,0 +1,138 @@ +import { DriftClient, PublicKey, User, UserAccount } from '@drift-labs/sdk'; +import { sleep } from '../core/async'; + +// When creating an account, try 5 times over 5 seconds to wait for the new account to hit the blockchain. +const ACCOUNT_INITIALIZATION_RETRY_DELAY_MS = 1000; +const ACCOUNT_INITIALIZATION_RETRY_ATTEMPTS = 5; + +const awaitAccountInitializationChainState = async ( + driftClient: DriftClient, + userId: number, + authority: PublicKey +) => { + const user = driftClient.getUser(userId, authority); + + if (!user.isSubscribed) { + await user.subscribe(); + } + + let retryCount = 0; + + do { + try { + await updateUserAccount(user); + if (user?.getUserAccountAndSlot()?.data !== undefined) { + return true; + } + } catch (err) { + retryCount++; + await sleep(ACCOUNT_INITIALIZATION_RETRY_DELAY_MS); + } + } while (retryCount < ACCOUNT_INITIALIZATION_RETRY_ATTEMPTS); + + throw new Error('awaitAccountInitializationFailed'); +}; + +/** + * Using your own callback to do the account initialization, this method will run the initialization step, switch to the drift user, await for the account to be available on chain, subscribe to the user account, and switch to the user account using the drift client. + * + * It provides extra callbacks to handle steps directly after the initialiation tx, and after fully initializing+subscribing to the account. + * + * Callbacks available: + * - initializationStep: This callback should send the transaction to initialize the user account + * - postInitializationStep: This callback will run after the successful initialization transaction, but before trying to load/subscribe to the new account + * - handleSuccessStep: This callback will run after everything has initialized+subscribed successfully + * + * // TODO : Need to do the subscription step + */ +const initializeAndSubscribeToNewUserAccount = async ( + driftClient: DriftClient, + userIdToInit: number, + authority: PublicKey, + callbacks: { + initializationStep: () => Promise; + postInitializationStep?: () => Promise; + handleSuccessStep?: (accountAlreadyExisted: boolean) => Promise; + } +): Promise< + | 'ok' + | 'failed_initializationStep' + | 'failed_postInitializationStep' + | 'failed_awaitAccountInitializationChainState' + | 'failed_handleSuccessStep' +> => { + await driftClient.addUser(userIdToInit, authority); + + const accountAlreadyExisted = await driftClient + .getUser(userIdToInit) + ?.exists(); + + // Do the account initialization step + let result = await callbacks.initializationStep(); + + // Fetch account to make sure it's loaded + await updateUserAccount(driftClient.getUser(userIdToInit)); + + if (!result) { + return 'failed_initializationStep'; + } + + // Do the post-initialization step + result = callbacks.postInitializationStep + ? await callbacks.postInitializationStep() + : result; + + if (!result) { + return 'failed_postInitializationStep'; + } + + // Await the account initialization step to update the blockchain + result = await awaitAccountInitializationChainState( + driftClient, + userIdToInit, + authority + ); + + if (!result) { + return 'failed_awaitAccountInitializationChainState'; + } + + await driftClient.switchActiveUser(userIdToInit, authority); + + // Do the subscription step + + // Run the success handler + result = callbacks.handleSuccessStep + ? await callbacks.handleSuccessStep(accountAlreadyExisted) + : result; + + if (!result) { + return 'failed_handleSuccessStep'; + } + + return 'ok'; +}; + +async function updateUserAccount(user: User): Promise { + const publicKey = user.userAccountPublicKey; + try { + const dataAndContext = + await user.driftClient.program.account.user.fetchAndContext( + publicKey, + 'processed' + ); + user.accountSubscriber.updateData( + dataAndContext.data as UserAccount, + dataAndContext.context.slot + ); + } catch (e) { + // noop + } +} + +export { + ACCOUNT_INITIALIZATION_RETRY_DELAY_MS, + ACCOUNT_INITIALIZATION_RETRY_ATTEMPTS, + awaitAccountInitializationChainState, + initializeAndSubscribeToNewUserAccount, +}; diff --git a/common-ts/src/utils/accounts/keys.ts b/common-ts/src/utils/accounts/keys.ts new file mode 100644 index 00000000..5c45ad14 --- /dev/null +++ b/common-ts/src/utils/accounts/keys.ts @@ -0,0 +1,40 @@ +import { MarketType, PublicKey } from '@drift-labs/sdk'; +import { getCachedUiString } from '../core/cache'; +import { ENUM_UTILS } from '../enum'; + +/** + * Get a unique key for an authority's subaccount + * @param userId + * @param authority + * @returns + */ +const getUserKey = (userId: number, authority: PublicKey) => { + if (userId == undefined || !authority) return ''; + return getCachedUiString('userKey', userId, authority.toString()); +}; + +/** + * Get the authority and subAccountId from a user's account key + * @param key + * @returns + */ +const getIdAndAuthorityFromKey = ( + key: string +): + | { userId: number; userAuthority: PublicKey } + | { userId: undefined; userAuthority: undefined } => { + const splitKey = key?.split('_'); + + if (!splitKey || splitKey.length !== 2) + return { userId: undefined, userAuthority: undefined }; + + return { + userId: Number(splitKey[0]), + userAuthority: new PublicKey(splitKey[1]), + }; +}; + +const getMarketKey = (marketIndex: number, marketType: MarketType) => + getCachedUiString('marketKey', ENUM_UTILS.toStr(marketType), marketIndex); + +export { getUserKey, getIdAndAuthorityFromKey, getMarketKey }; diff --git a/common-ts/src/utils/accounts/multiple.ts b/common-ts/src/utils/accounts/multiple.ts new file mode 100644 index 00000000..aca0e43e --- /dev/null +++ b/common-ts/src/utils/accounts/multiple.ts @@ -0,0 +1,76 @@ +import { PublicKey } from '@drift-labs/sdk'; +import { AccountInfo, Connection } from '@solana/web3.js'; +import { chunks } from '../core/arrays'; + +const getMultipleAccounts = async ( + connection: any, + keys: string[], + commitment: string +) => { + const result = await Promise.all( + chunks(keys, 99).map((chunk) => + getMultipleAccountsCore(connection, chunk, commitment) + ) + ); + + const array = result + .map( + (a) => + a.array + .map((acc) => { + if (!acc) { + return undefined; + } + + const { data, ...rest } = acc; + const obj = { + ...rest, + data: Buffer.from(data[0], 'base64'), + } as AccountInfo; + return obj; + }) + .filter((_) => _) as AccountInfo[] + ) + .flat(); + return { keys, array }; +}; + +const getMultipleAccountsCore = async ( + connection: any, + keys: string[], + commitment: string +) => { + const args = connection._buildArgs([keys], commitment, 'base64'); + + const unsafeRes = await connection._rpcRequest('getMultipleAccounts', args); + if (unsafeRes.error) { + throw new Error( + 'failed to get info about account ' + unsafeRes.error.message + ); + } + + if (unsafeRes.result.value) { + const array = unsafeRes.result.value as AccountInfo[]; + return { keys, array }; + } + + // TODO: fix + throw new Error(); +}; + +const getMultipleAccountsInfoChunked = async ( + connection: Connection, + accounts: PublicKey[] +): Promise<(AccountInfo | null)[]> => { + const accountChunks = chunks(accounts, 100); // 100 is limit for getMultipleAccountsInfo + const responses = await Promise.all( + accountChunks.map((chunk) => connection.getMultipleAccountsInfo(chunk)) + ); + return responses.flat(); +}; + +export { + getMultipleAccounts, + getMultipleAccountsCore, + getMultipleAccountsInfoChunked, +}; diff --git a/common-ts/src/utils/accounts/signature.ts b/common-ts/src/utils/accounts/signature.ts new file mode 100644 index 00000000..9d9f6304 --- /dev/null +++ b/common-ts/src/utils/accounts/signature.ts @@ -0,0 +1,43 @@ +import { PublicKey } from '@drift-labs/sdk'; +import bcrypt from 'bcryptjs-react'; +import nacl, { sign } from 'tweetnacl'; + +const getSignatureVerificationMessageForSettings = ( + authority: PublicKey, + signTs: number +): Uint8Array => { + return new TextEncoder().encode( + `Verify you are the owner of this wallet to update trade settings: \n${authority.toBase58()}\n\nThis signature will be valid for the next 30 minutes.\n\nTS: ${signTs.toString()}` + ); +}; + +const verifySignature = ( + signature: Uint8Array, + message: Uint8Array, + pubKey: PublicKey +): boolean => { + return sign.detached.verify(message, signature, pubKey.toBytes()); +}; + +const hashSignature = async (signature: string): Promise => { + bcrypt.setRandomFallback((num: number) => { + return Array.from(nacl.randomBytes(num)); + }); + const hashedSignature = await bcrypt.hash(signature, bcrypt.genSaltSync(10)); + return hashedSignature; +}; + +const compareSignatures = async ( + original: string, + hashed: string +): Promise => { + const signaturesMatch = await bcrypt.compare(original, hashed); + return signaturesMatch; +}; + +export { + getSignatureVerificationMessageForSettings, + verifySignature, + hashSignature, + compareSignatures, +}; diff --git a/common-ts/src/utils/accounts/subaccounts.ts b/common-ts/src/utils/accounts/subaccounts.ts new file mode 100644 index 00000000..7bdf8159 --- /dev/null +++ b/common-ts/src/utils/accounts/subaccounts.ts @@ -0,0 +1,38 @@ +import { DriftClient, PublicKey, User, UserAccount } from '@drift-labs/sdk'; + +const fetchCurrentSubaccounts = (driftClient: DriftClient): UserAccount[] => { + return driftClient.getUsers().map((user) => user.getUserAccount()); +}; + +const fetchUserClientsAndAccounts = ( + driftClient: DriftClient +): { user: User; userAccount: UserAccount }[] => { + const accounts = fetchCurrentSubaccounts(driftClient); + const allUsersAndUserAccounts = accounts.map((acct) => { + return { + user: driftClient.getUser(acct.subAccountId, acct.authority), + userAccount: acct, + }; + }); + + return allUsersAndUserAccounts; +}; + +const userExists = async ( + driftClient: DriftClient, + userId: number, + authority: PublicKey +) => { + let userAccountExists = false; + + try { + const user = driftClient.getUser(userId, authority); + userAccountExists = await user.exists(); + } catch (e) { + // user account does not exist so we leave userAccountExists false + } + + return userAccountExists; +}; + +export { fetchCurrentSubaccounts, fetchUserClientsAndAccounts, userExists }; diff --git a/common-ts/src/utils/WalletConnectionState.ts b/common-ts/src/utils/accounts/wallet.ts similarity index 76% rename from common-ts/src/utils/WalletConnectionState.ts rename to common-ts/src/utils/accounts/wallet.ts index 02a38dd0..3f4d8c30 100644 --- a/common-ts/src/utils/WalletConnectionState.ts +++ b/common-ts/src/utils/accounts/wallet.ts @@ -1,4 +1,5 @@ -import { PublicKey } from '@drift-labs/sdk'; +import { IWalletV2, PublicKey } from '@drift-labs/sdk'; +import { Keypair } from '@solana/web3.js'; export enum ConnectionStateSteps { NotConnected = 0, @@ -103,3 +104,36 @@ export class WalletConnectionState { return this.is('SubaccountsSubscribed'); } } + +/** + * Creates an IWallet wrapper, with redundant methods. If a `walletPubKey` is passed in, + * the `publicKey` will be based on that. + */ +const createPlaceholderIWallet = (walletPubKey?: PublicKey) => { + const newKeypair = walletPubKey + ? new Keypair({ + publicKey: walletPubKey.toBytes(), + secretKey: new Keypair().publicKey.toBytes(), + }) + : new Keypair(); + + const newWallet: IWalletV2 = { + publicKey: newKeypair.publicKey, + //@ts-ignore + signTransaction: () => { + return Promise.resolve(); + }, + //@ts-ignore + signAllTransactions: () => { + return Promise.resolve(); + }, + //@ts-ignore + signMessage: () => { + return Promise.resolve(); + }, + }; + + return newWallet; +}; + +export { createPlaceholderIWallet }; diff --git a/common-ts/src/utils/core/arrays.ts b/common-ts/src/utils/core/arrays.ts new file mode 100644 index 00000000..372f02f6 --- /dev/null +++ b/common-ts/src/utils/core/arrays.ts @@ -0,0 +1,21 @@ +export const chunks = (array: readonly T[], size: number): T[][] => { + return new Array(Math.ceil(array.length / size)) + .fill(null) + .map((_, index) => index * size) + .map((begin) => array.slice(begin, begin + size)); +}; + +export const glueArray = (size: number, elements: T[]): T[][] => { + const gluedElements: T[][] = []; + + elements.forEach((element, index) => { + const gluedIndex = Math.floor(index / size); + if (gluedElements[gluedIndex]) { + gluedElements[gluedIndex].push(element); + } else { + gluedElements[gluedIndex] = [element]; + } + }); + + return gluedElements; +}; diff --git a/common-ts/src/utils/core/async.ts b/common-ts/src/utils/core/async.ts new file mode 100644 index 00000000..ebbeec5e --- /dev/null +++ b/common-ts/src/utils/core/async.ts @@ -0,0 +1,13 @@ +export async function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const timedPromise = async (promise: T) => { + const start = Date.now(); + const promiseResult = await promise; + + return { + promiseTime: Date.now() - start, + promiseResult, + }; +}; diff --git a/common-ts/src/utils/core/cache.ts b/common-ts/src/utils/core/cache.ts new file mode 100644 index 00000000..7cf180f9 --- /dev/null +++ b/common-ts/src/utils/core/cache.ts @@ -0,0 +1,45 @@ +// Cache for common UI string patterns to reduce memory allocation +const uiStringCache = new Map(); +const MAX_UI_STRING_CACHE_SIZE = 2000; + +// Helper function to cache common string patterns +export function getCachedUiString( + pattern: string, + ...values: (string | number)[] +): string { + const cacheKey = `${pattern}:${values.join(':')}`; + + if (uiStringCache.has(cacheKey)) { + return uiStringCache.get(cacheKey)!; + } + + let result: string; + switch (pattern) { + case 'abbreviate': { + const [authString, length] = values as [string, number]; + result = `${authString.slice(0, length)}\u2026${authString.slice( + -length + )}`; + break; + } + case 'userKey': { + const [userId, authority] = values as [number, string]; + result = `${userId}_${authority}`; + break; + } + case 'marketKey': { + const [marketType, marketIndex] = values as [string, number]; + result = `${marketType}_${marketIndex}`; + break; + } + default: + result = values.join('_'); + } + + // Cache if not too large + if (uiStringCache.size < MAX_UI_STRING_CACHE_SIZE) { + uiStringCache.set(cacheKey, result); + } + + return result; +} diff --git a/common-ts/src/utils/core/data-structures.ts b/common-ts/src/utils/core/data-structures.ts new file mode 100644 index 00000000..e772d726 --- /dev/null +++ b/common-ts/src/utils/core/data-structures.ts @@ -0,0 +1,94 @@ +export class Ref { + public val: T; + + constructor(val: T) { + this.val = val; + } + + set(val: T) { + this.val = val; + } + + get() { + return this.val; + } +} + +export class Counter { + private val = 0; + + get() { + return this.val; + } + + increment(value = 1) { + this.val += value; + } + + reset() { + this.val = 0; + } +} + +/** + * A class which allows a group of switches to seperately turn a multiswitch on or off. The base state is the state of the "multiswitch" when all of the constituent switches are off. When any of the switches are "on" then the multiswitch flips to the opposite state + * + * If baseState is on => any switch being "on" will turn the multiswitch off. + * If baseState is off => any switch being "off" will turn the multiswitch off. + */ +export class MultiSwitch { + private switches: string[] = []; + private switchValue = 0; + + constructor(private baseState: 'on' | 'off' = 'off') {} + + private getSwitchKey(key: string) { + // If first time using switch, add to list of switches + if (!this.switches.includes(key)) { + this.switches.push(key); + } + + const switchIndex = this.switches.indexOf(key); + + return 2 ** switchIndex; + } + + public switchOn(key: string) { + if (this.baseState === 'on') { + this._switchOff(key); + return; + } + this._switchOn(key); + } + + public switchOff(key: string) { + if (this.baseState === 'on') { + this._switchOn(key); + return; + } + this._switchOff(key); + } + + private _switchOff(key: string) { + const switchKey = this.getSwitchKey(key); + + this.switchValue &= ~switchKey; + } + + private _switchOn(key: string) { + const switchKey = this.getSwitchKey(key); + + this.switchValue |= switchKey; + } + + public get isOn() { + // When the base state is on, then if any switch is on the multi-switch is off + if (this.baseState === 'on') { + return this.switchValue === 0; + } + + if (this.baseState === 'off') { + return this.switchValue > 0; + } + } +} diff --git a/common-ts/src/utils/equalityChecks.ts b/common-ts/src/utils/core/equality.ts similarity index 96% rename from common-ts/src/utils/equalityChecks.ts rename to common-ts/src/utils/core/equality.ts index 52eb6f80..c42d9143 100644 --- a/common-ts/src/utils/equalityChecks.ts +++ b/common-ts/src/utils/core/equality.ts @@ -1,5 +1,5 @@ -import { ENUM_UTILS } from '.'; -import { OpenPosition } from '../types'; +import { ENUM_UTILS } from '../enum'; +import { OpenPosition } from '../../types'; export type PropertyType = | 'primitive' diff --git a/common-ts/src/utils/fetch.ts b/common-ts/src/utils/core/fetch.ts similarity index 100% rename from common-ts/src/utils/fetch.ts rename to common-ts/src/utils/core/fetch.ts diff --git a/common-ts/src/utils/core/index.ts b/common-ts/src/utils/core/index.ts new file mode 100644 index 00000000..7fb56238 --- /dev/null +++ b/common-ts/src/utils/core/index.ts @@ -0,0 +1,7 @@ +export * from './async'; +export * from './arrays'; +export * from './data-structures'; +export * from './cache'; +export * from './fetch'; +export * from './serialization'; +export * from './equality'; diff --git a/common-ts/src/utils/core/serialization.ts b/common-ts/src/utils/core/serialization.ts new file mode 100644 index 00000000..58ef3f9e --- /dev/null +++ b/common-ts/src/utils/core/serialization.ts @@ -0,0 +1,101 @@ +import { BN, PublicKey } from '@drift-labs/sdk'; + +const getStringifiableObjectEntry = (value: any): [any, string] => { + // If BN + // if (value instanceof BN) { /* This method would be much safer but don't think it's possible to ensure that instances of classes match when they're in different npm packages */ + if (Object.keys(value).sort().join(',') === 'length,negative,red,words') { + return [value.toString(), '_bgnm_']; + } + + // If PublicKey + // if (value instanceof PublicKey) { { /* This method would be much safer but don't think it's possible to ensure that instances of classes match when they're in different npm packages */ + if (Object.keys(value).sort().join(',') === '_bn') { + return [value.toString(), '_pbky_']; + } + + if (typeof value === 'object') { + return [encodeStringifiableObject(value), '']; + } + + return [value, '']; +}; + +/** + * Converts an objects with potential Pubkeys and BNs in it into a form that can be JSON stringified. When pubkeys get converted a _pbky_ suffix will be added to their key, and _bgnm_ for BNs. + * + * e.g. + * input : { + * QuoteAmount: BN + * } + * + * output: { + * _bgnm_QuoteAmount: string + * } + * @param value + * @returns + */ +export const encodeStringifiableObject = (value: any) => { + if (typeof value !== 'object') return value; + + if (Array.isArray(value)) { + return value.map((entry) => encodeStringifiableObject(entry)); + } + + const buildJsonObject = {}; + + if (!value) return value; + + Object.entries(value).forEach(([key, val]) => { + const [convertedVal, keyTag] = getStringifiableObjectEntry(val); + buildJsonObject[`${keyTag}${key}`] = convertedVal; + }); + + return buildJsonObject; +}; + +/** + * Converts a parsed object with potential Pubkeys and BNs in it (in string form) to their proper form. Pubkey values must have a key starting in _pbky_ and BN values must have a key starting in _bnnm_ + * + * * e.g. + * input : { + * _bgnm_QuoteAmount: string + * } + * + * output: { + * QuoteAmount: BN + * } + * @param value + * @returns + */ +export const decodeStringifiableObject = (value: any) => { + if (typeof value !== 'object') return value; + + if (Array.isArray(value)) { + return value.map((entry) => decodeStringifiableObject(entry)); + } + + const buildJsonObject = {}; + + Object.entries(value) + .filter((val) => val != undefined && val != null) + .forEach(([key, val]) => { + if (key.match(/^_pbky_/)) { + buildJsonObject[key.replace('_pbky_', '')] = new PublicKey(val); + return; + } + + if (key.match(/^_bgnm_/)) { + buildJsonObject[key.replace('_bgnm_', '')] = new BN(val as string); + return; + } + + if (typeof val === 'object' && val != undefined && val != null) { + buildJsonObject[key] = decodeStringifiableObject(val); + return; + } + + buildJsonObject[key] = val; + }); + + return buildJsonObject; +}; diff --git a/common-ts/src/utils/enum.ts b/common-ts/src/utils/enum/index.ts similarity index 100% rename from common-ts/src/utils/enum.ts rename to common-ts/src/utils/enum/index.ts diff --git a/common-ts/src/utils/index.ts b/common-ts/src/utils/index.ts index e04d05b6..8aca9481 100644 --- a/common-ts/src/utils/index.ts +++ b/common-ts/src/utils/index.ts @@ -1,864 +1,11 @@ -import { - AMM_RESERVE_PRECISION_EXP, - BASE_PRECISION, - BN, - Event, - PRICE_PRECISION, - OrderAction, - OrderActionRecord, - OrderRecord, - PublicKey, - QUOTE_PRECISION, - ZERO, - PRICE_PRECISION_EXP, - BASE_PRECISION_EXP, - BigNum, - MarketType, - DriftClient, - SPOT_MARKET_RATE_PRECISION_EXP, - calculateDepositRate, - calculateBorrowRate, - getTokenAmount, - SpotBalanceType, - SpotMarketConfig, -} from '@drift-labs/sdk'; -import { - UIMatchedOrderRecordAndAction, - UISerializableOrderActionRecord, -} from '../serializableTypes'; -import { getIfStakingVaultApr, getIfVaultBalance } from './insuranceFund'; -import { AccountInfo, Connection } from '@solana/web3.js'; - -import { matchEnum, ENUM_UTILS } from './enum'; -export { matchEnum, ENUM_UTILS }; - -export async function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} -const getStringifiableObjectEntry = (value: any): [any, string] => { - // If BN - // if (value instanceof BN) { /* This method would be much safer but don't think it's possible to ensure that instances of classes match when they're in different npm packages */ - if (Object.keys(value).sort().join(',') === 'length,negative,red,words') { - return [value.toString(), '_bgnm_']; - } - - // If PublicKey - // if (value instanceof PublicKey) { { /* This method would be much safer but don't think it's possible to ensure that instances of classes match when they're in different npm packages */ - if (Object.keys(value).sort().join(',') === '_bn') { - return [value.toString(), '_pbky_']; - } - - if (typeof value === 'object') { - return [encodeStringifiableObject(value), '']; - } - - return [value, '']; -}; - -/** - * Converts an objects with potential Pubkeys and BNs in it into a form that can be JSON stringified. When pubkeys get converted a _pbky_ suffix will be added to their key, and _bgnm_ for BNs. - * - * e.g. - * input : { - * QuoteAmount: BN - * } - * - * output: { - * _bgnm_QuoteAmount: string - * } - * @param value - * @returns - */ -export const encodeStringifiableObject = (value: any) => { - if (typeof value !== 'object') return value; - - if (Array.isArray(value)) { - return value.map((entry) => encodeStringifiableObject(entry)); - } - - const buildJsonObject = {}; - - if (!value) return value; - - Object.entries(value).forEach(([key, val]) => { - const [convertedVal, keyTag] = getStringifiableObjectEntry(val); - buildJsonObject[`${keyTag}${key}`] = convertedVal; - }); - - return buildJsonObject; -}; - -/** - * Converts a parsed object with potential Pubkeys and BNs in it (in string form) to their proper form. Pubkey values must have a key starting in _pbky_ and BN values must have a key starting in _bnnm_ - * - * * e.g. - * input : { - * _bgnm_QuoteAmount: string - * } - * - * output: { - * QuoteAmount: BN - * } - * @param value - * @returns - */ -export const decodeStringifiableObject = (value: any) => { - if (typeof value !== 'object') return value; - - if (Array.isArray(value)) { - return value.map((entry) => decodeStringifiableObject(entry)); - } - - const buildJsonObject = {}; - - Object.entries(value) - .filter((val) => val != undefined && val != null) - .forEach(([key, val]) => { - if (key.match(/^_pbky_/)) { - buildJsonObject[key.replace('_pbky_', '')] = new PublicKey(val); - return; - } - - if (key.match(/^_bgnm_/)) { - buildJsonObject[key.replace('_bgnm_', '')] = new BN(val as string); - return; - } - - if (typeof val === 'object' && val != undefined && val != null) { - buildJsonObject[key] = decodeStringifiableObject(val); - return; - } - - buildJsonObject[key] = val; - }); - - return buildJsonObject; -}; - -const getChronologicalValueForOrderAction = (action: OrderAction) => { - return matchEnum(action, OrderAction.PLACE) - ? 0 - : matchEnum(action, OrderAction.FILL) - ? 1 - : 2; -}; - -/** - * Returns 1 if the first Order is chronologically later than the second Order, -1 if before, 0 if equal - * @param orderA - * @param orderB - * @returns - */ -export const getSortScoreForOrderRecords = ( - orderA: { slot: number }, - orderB: { slot: number } -) => { - if (orderA.slot !== orderB.slot) { - return orderA.slot > orderB.slot ? 1 : -1; - } - - return 0; -}; - -export const getTradeInfoFromActionRecord = ( - actionRecord: PartialUISerializableOrderActionRecord -) => { - return { - ts: actionRecord.ts, - baseAssetAmount: actionRecord.taker - ? actionRecord.takerOrderBaseAssetAmount - : actionRecord.makerOrderBaseAssetAmount, - baseAssetAmountFilled: actionRecord.taker - ? actionRecord.takerOrderCumulativeBaseAssetAmountFilled - : actionRecord.makerOrderCumulativeBaseAssetAmountFilled, - quoteAssetAmountFilled: actionRecord.taker - ? actionRecord.takerOrderCumulativeQuoteAssetAmountFilled - : actionRecord.makerOrderCumulativeQuoteAssetAmountFilled, - }; -}; - -export type PartialOrderActionRecord = - | PartialUISerializableOrderActionRecord - | PartialOrderActionEventRecord; - -export type PartialUISerializableOrderActionRecord = Pick< - UISerializableOrderActionRecord, - | 'quoteAssetAmountFilled' - | 'baseAssetAmountFilled' - | 'ts' - | 'slot' - | 'action' - | 'fillRecordId' - | 'taker' - | 'takerOrderBaseAssetAmount' - | 'makerOrderBaseAssetAmount' - | 'takerOrderCumulativeBaseAssetAmountFilled' - | 'makerOrderCumulativeBaseAssetAmountFilled' - | 'takerOrderCumulativeQuoteAssetAmountFilled' - | 'makerOrderCumulativeQuoteAssetAmountFilled' - | 'oraclePrice' ->; - -export type PartialOrderActionEventRecord = Pick< - Event, - | 'quoteAssetAmountFilled' - | 'baseAssetAmountFilled' - | 'ts' - | 'slot' - | 'action' - | 'fillRecordId' - | 'taker' - | 'takerOrderBaseAssetAmount' - | 'makerOrderBaseAssetAmount' - | 'takerOrderCumulativeBaseAssetAmountFilled' - | 'makerOrderCumulativeBaseAssetAmountFilled' - | 'takerOrderCumulativeQuoteAssetAmountFilled' - | 'makerOrderCumulativeQuoteAssetAmountFilled' ->; - -/** - * Returns 1 if the first Order is chronologically later than the second Order, -1 if before, 0 if equal - * @param orderA - * @param orderB - * @returns - */ -export const getSortScoreForOrderActionRecords = ( - orderA: PartialOrderActionRecord, - orderB: PartialOrderActionRecord -) => { - if (orderA.slot !== orderB.slot) { - return orderA.slot > orderB.slot ? 1 : -1; - } - - if (!matchEnum(orderA.action, orderB.action)) { - // @ts-ignore - const orderAActionVal = getChronologicalValueForOrderAction(orderA.action); - // @ts-ignore - const orderBActionVal = getChronologicalValueForOrderAction(orderB.action); - - return orderAActionVal > orderBActionVal ? 1 : -1; - } - // @ts-ignore - if (orderA.fillRecordId && orderB.fillRecordId) { - if (!orderA.fillRecordId.eq(orderB.fillRecordId)) { - // @ts-ignore - return orderA.fillRecordId.gt(orderB.fillRecordId) ? 1 : -1; - } - } - - return 0; -}; - -export const sortUIMatchedOrderRecordAndAction = ( - records: UIMatchedOrderRecordAndAction[], - direction: 'asc' | 'desc' = 'desc' -) => { - const ascSortedRecords = records.sort((a, b) => - getSortScoreForOrderActionRecords(a.actionRecord, b.actionRecord) - ); - - return direction === 'desc' ? ascSortedRecords.reverse() : ascSortedRecords; -}; - -export const sortUIOrderActionRecords = ( - records: PartialUISerializableOrderActionRecord[], - direction: 'asc' | 'desc' = 'desc' -) => { - const ascSortedRecords = records.sort(getSortScoreForOrderActionRecords); - - return direction === 'desc' ? ascSortedRecords.reverse() : ascSortedRecords; -}; - -export const sortUIOrderRecords = ( - records: T[], - direction: 'asc' | 'desc' = 'desc' -) => { - const ascSortedRecords = records.sort(getSortScoreForOrderRecords); - - return direction === 'desc' ? ascSortedRecords.reverse() : ascSortedRecords; -}; - -export const sortOrderRecords = ( - records: Event[], - direction: 'asc' | 'desc' = 'desc' -) => { - const ascSortedRecords = records.sort(getSortScoreForOrderRecords); - - return direction === 'desc' ? ascSortedRecords.reverse() : ascSortedRecords; -}; - -export const getLatestOfTwoUIOrderRecords = ( - orderA: T, - orderB: T -) => { - return getSortScoreForOrderRecords(orderA, orderB) === 1 ? orderA : orderB; -}; - -export const getLatestOfTwoOrderRecords = ( - orderA: T, - orderB: T -) => { - return getSortScoreForOrderRecords(orderA, orderB) === 1 ? orderA : orderB; -}; - -export const getUIOrderRecordsLaterThanTarget = ( - target: T, - records: T[] -) => - records.filter( - (record) => getLatestOfTwoUIOrderRecords(record, target) === record - ); - -// Trade records are order records which have been filled -export const orderActionRecordIsTrade = (orderRecord: OrderActionRecord) => - orderRecord.baseAssetAmountFilled.gt(ZERO) && - // @ts-ignore - matchEnum(orderRecord.action, OrderAction.FILL) && - true; - -export const uiOrderActionRecordIsTrade = ( - orderRecord: UISerializableOrderActionRecord -) => - orderRecord.baseAssetAmountFilled.gtZero() && - matchEnum(orderRecord.action, OrderAction.FILL) && - true; - -// Trade records are order records which have been filled -export const filterTradeRecordsFromOrderActionRecords = ( - orderRecords: OrderActionRecord[] -): OrderActionRecord[] => orderRecords.filter(orderActionRecordIsTrade); - -// Trade records are order records which have been filled -export const filterTradeRecordsFromUIOrderRecords = ( - orderRecords: UISerializableOrderActionRecord[] -): UISerializableOrderActionRecord[] => - orderRecords.filter(uiOrderActionRecordIsTrade); - -/** - * Returns the average price for a given base amount and quote amount. - * @param quoteAmount - * @param baseAmount - * @returns PRICE_PRECISION - */ -export const getPriceForBaseAndQuoteAmount = ( - quoteAmount: BN, - baseAmount: BN -) => { - return quoteAmount - .mul(PRICE_PRECISION) - .mul(BASE_PRECISION) - .div(QUOTE_PRECISION) - .div(BigNum.from(baseAmount, BASE_PRECISION_EXP).val); -}; - -export const getPriceForOrderRecord = ( - orderRecord: Pick< - OrderActionRecord, - 'quoteAssetAmountFilled' | 'baseAssetAmountFilled' - > -) => { - return getPriceForBaseAndQuoteAmount( - // @ts-ignore - orderRecord.quoteAssetAmountFilled, - // @ts-ignore - orderRecord.baseAssetAmountFilled - ); -}; - -export const getPriceForUIOrderRecord = ( - orderRecord: Pick< - UISerializableOrderActionRecord, - 'quoteAssetAmountFilled' | 'baseAssetAmountFilled' - > -) => { - return orderRecord.quoteAssetAmountFilled - .shiftTo(AMM_RESERVE_PRECISION_EXP) - .shift(PRICE_PRECISION_EXP) - .div(orderRecord.baseAssetAmountFilled.shiftTo(BASE_PRECISION_EXP)) - .shiftTo(PRICE_PRECISION_EXP); -}; - -export const orderIsNull = ( - order: UISerializableOrderActionRecord | Event, - side: 'taker' | 'maker' -) => { - return side === 'taker' ? !order.taker : !order.maker; -}; - -export const getAnchorEnumString = (enumVal: Record) => { - return Object.keys(enumVal)[0]; -}; -export class Ref { - public val: T; - - constructor(val: T) { - this.val = val; - } - - set(val: T) { - this.val = val; - } - - get() { - return this.val; - } -} - -export class Counter { - private val = 0; - - get() { - return this.val; - } - - increment(value = 1) { - this.val += value; - } - - reset() { - this.val = 0; - } -} - -/** - * A class which allows a group of switches to seperately turn a multiswitch on or off. The base state is the state of the "multiswitch" when all of the constituent switches are off. When any of the switches are "on" then the multiswitch flips to the opposite state - * - * If baseState is on => any switch being "on" will turn the multiswitch off. - * If baseState is off => any switch being "off" will turn the multiswitch off. - */ -export class MultiSwitch { - private switches: string[] = []; - private switchValue = 0; - - constructor(private baseState: 'on' | 'off' = 'off') {} - - private getSwitchKey(key: string) { - // If first time using switch, add to list of switches - if (!this.switches.includes(key)) { - this.switches.push(key); - } - - const switchIndex = this.switches.indexOf(key); - - return 2 ** switchIndex; - } - - public switchOn(key: string) { - if (this.baseState === 'on') { - this._switchOff(key); - return; - } - this._switchOn(key); - } - - public switchOff(key: string) { - if (this.baseState === 'on') { - this._switchOn(key); - return; - } - this._switchOff(key); - } - - private _switchOff(key: string) { - const switchKey = this.getSwitchKey(key); - - this.switchValue &= ~switchKey; - } - - private _switchOn(key: string) { - const switchKey = this.getSwitchKey(key); - - this.switchValue |= switchKey; - } - - public get isOn() { - // When the base state is on, then if any switch is on the multi-switch is off - if (this.baseState === 'on') { - return this.switchValue === 0; - } - - if (this.baseState === 'off') { - return this.switchValue > 0; - } - } -} - -/** - * Returns the quote amount of the current open interest for a market, using the current oracle price - * @param marketIndex - * @param marketType - * @param driftClient - * @returns - */ -const getCurrentOpenInterestForMarket = ( - marketIndex: number, - marketType: MarketType, - driftClient: DriftClient -) => { - if (ENUM_UTILS.match(marketType, MarketType.PERP)) { - const market = driftClient.getPerpMarketAccount(marketIndex); - const OI = BigNum.from( - market.amm.baseAssetAmountLong.add(market.amm.baseAssetAmountShort.abs()), - BASE_PRECISION_EXP - ); - - const priceData = driftClient.getOraclePriceDataAndSlot( - market.amm.oracle, - market.amm.oracleSource - ); - - const price = BigNum.from(priceData.data.price, PRICE_PRECISION_EXP); - - const quoteOIforMarket = price.toNum() * OI.toNum(); - - return quoteOIforMarket; - } else { - throw new Error('Invalid market type for Open Interest calculation'); - } -}; - -/** - * Gets the deposit APR for a spot market, in percent - * @param marketIndex - * @param marketType - * @param driftClient - * @returns - */ -const getDepositAprForMarket = ( - marketIndex: number, - marketType: MarketType, - driftClient: DriftClient -) => { - if (ENUM_UTILS.match(marketType, MarketType.SPOT)) { - const marketAccount = driftClient.getSpotMarketAccount(marketIndex); - - const depositApr = BigNum.from( - calculateDepositRate(marketAccount), - SPOT_MARKET_RATE_PRECISION_EXP - ); - - const depositAprPct = depositApr.toNum() * 100; - - return depositAprPct; - } else { - throw new Error('Invalid market type for Deposit APR calculation'); - } -}; - -/** - * Get's the borrow APR for a spot market, in percent - * @param marketIndex - * @param marketType - * @param driftClient - * @returns - */ -const getBorrowAprForMarket = ( - marketIndex: number, - marketType: MarketType, - driftClient: DriftClient -) => { - if (ENUM_UTILS.match(marketType, MarketType.SPOT)) { - const marketAccount = driftClient.getSpotMarketAccount(marketIndex); - - const depositApr = BigNum.from( - calculateBorrowRate(marketAccount), - SPOT_MARKET_RATE_PRECISION_EXP - ); - - const depositAprPct = depositApr.toNum() * 100; - - return depositAprPct; - } else { - throw new Error('Invalid market type for Borrow APR calculation'); - } -}; - -const getTotalBorrowsForMarket = ( - market: SpotMarketConfig, - driftClient: DriftClient -) => { - const marketAccount = driftClient.getSpotMarketAccount(market.marketIndex); - - const totalBorrowsTokenAmount = getTokenAmount( - marketAccount.borrowBalance, - driftClient.getSpotMarketAccount(marketAccount.marketIndex), - SpotBalanceType.BORROW - ); - - const totalBorrowsAmountBigNum = BigNum.from( - totalBorrowsTokenAmount, - market.precisionExp - ); - - const priceData = driftClient.getOraclePriceDataAndSlot( - marketAccount.oracle, - marketAccount.oracleSource - ); - - const price = BigNum.from(priceData.data.price, PRICE_PRECISION_EXP); - - const totalBorrowsQuote = price.toNum() * totalBorrowsAmountBigNum.toNum(); - - return Number(totalBorrowsQuote.toFixed(2)); -}; - -const getTotalDepositsForMarket = ( - market: SpotMarketConfig, - driftClient: DriftClient -) => { - const marketAccount = driftClient.getSpotMarketAccount(market.marketIndex); - - const totalDepositsTokenAmount = getTokenAmount( - marketAccount.depositBalance, - marketAccount, - SpotBalanceType.DEPOSIT - ); - - const totalDepositsTokenAmountBigNum = BigNum.from( - totalDepositsTokenAmount, - market.precisionExp - ); - - const priceData = driftClient.getOraclePriceDataAndSlot( - marketAccount.oracle, - marketAccount.oracleSource - ); - - const price = BigNum.from(priceData.data.price, PRICE_PRECISION_EXP); - - const totalDepositsBase = totalDepositsTokenAmountBigNum.toNum(); - const totalDepositsQuote = - price.toNum() * totalDepositsTokenAmountBigNum.toNum(); - - return { - totalDepositsBase, - totalDepositsQuote, - }; -}; - -/** - * Check if numbers divide exactly, accounting for floating point division annoyingness - * @param numerator - * @param denominator - * @returns - */ -const dividesExactly = (numerator: number, denominator: number) => { - const division = numerator / denominator; - const remainder = division % 1; - - if (remainder === 0) return true; - - // Because of floating point weirdness, we're just going to assume that if the remainder after dividing is less than 1/10^6 then the numbers do divide exactly - if (Math.abs(remainder - 1) < 1 / 10 ** 6) return true; - - return false; -}; - -const toSnakeCase = (str: string): string => - str.replace(/[^\w]/g, '_').toLowerCase(); - -const toCamelCase = (str: string): string => { - const words = str.split(/[_\-\s]+/); // split on underscores, hyphens, and spaces - const firstWord = words[0].toLowerCase(); - const restWords = words - .slice(1) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()); - return [firstWord, ...restWords].join(''); -}; - -export const aprFromApy = (apy: number, compoundsPerYear: number) => { - const compoundedAmount = 1 + apy * 0.01; - const estimatedApr = - (Math.pow(compoundedAmount, 1 / compoundsPerYear) - 1) * compoundsPerYear; - - return estimatedApr * 100; -}; - -/** - * Helper utility to get a sort score for "tiered" parameters. - * - * Example: Want to sort students by Grade, but fall back to using Age if they are equal. This method will accept an array for each student of [grade, age] and return the appropriate sort score for each. - * - * @param aScores - * @param bScores - * @returns - */ -export const getTieredSortScore = (aScores: number[], bScores: number[]) => { - const maxIndex = Math.max(aScores.length, bScores.length); - - for (let i = 0; i < maxIndex; i++) { - const aScore = aScores[i] ?? Number.MIN_SAFE_INTEGER; - const bScore = bScores[i] ?? Number.MIN_SAFE_INTEGER; - - if (aScore !== bScore) return aScore - bScore; - } - - return 0; -}; - -const normalizeBaseAssetSymbol = (symbol: string) => { - return symbol.replace(/^1M/, ''); -}; - -/** - * Returns the number of standard deviations between a target value and the history of values to compare it to. - * @param target - * @param previousValues - * @returns - */ -const calculateZScore = (target: number, previousValues: number[]): number => { - const meanValue = calculateMean(previousValues); - const standardDeviationValue = calculateStandardDeviation( - previousValues, - meanValue - ); - - const zScore = (target - meanValue) / standardDeviationValue; - return zScore; -}; - -const calculateMean = (numbers: number[]): number => { - const sum = numbers.reduce((total, num) => total + num, 0); - return sum / numbers.length; -}; - -const calculateMedian = (numbers: number[]): number => { - const sortedNumbers = numbers.sort(); - const middleIndex = Math.floor(sortedNumbers.length / 2); - if (sortedNumbers.length % 2 === 0) { - return (sortedNumbers[middleIndex - 1] + sortedNumbers[middleIndex]) / 2; - } else { - return sortedNumbers[middleIndex]; - } -}; - -const calculateStandardDeviation = ( - numbers: number[], - mean: number -): number => { - const squaredDifferences = numbers.map((num) => Math.pow(num - mean, 2)); - const sumSquaredDifferences = squaredDifferences.reduce( - (total, diff) => total + diff, - 0 - ); - const variance = sumSquaredDifferences / numbers.length; - return Math.sqrt(variance); -}; - -const glueArray = (size: number, elements: T[]): T[][] => { - const gluedElements: T[][] = []; - - elements.forEach((element, index) => { - const gluedIndex = Math.floor(index / size); - if (gluedElements[gluedIndex]) { - gluedElements[gluedIndex].push(element); - } else { - gluedElements[gluedIndex] = [element]; - } - }); - - return gluedElements; -}; - -const bnMin = (numbers: BN[]): BN => { - let min = numbers[0]; - for (let i = 1; i < numbers.length; i++) { - if (numbers[i].lt(min)) { - min = numbers[i]; - } - } - return min; -}; - -const bnMax = (numbers: BN[]): BN => { - let max = numbers[0]; - for (let i = 1; i < numbers.length; i++) { - if (numbers[i].gt(max)) { - max = numbers[i]; - } - } - return max; -}; - -const bnMedian = (numbers: BN[]): BN => { - const sortedNumbers = numbers.sort((a, b) => a.cmp(b)); - const middleIndex = Math.floor(sortedNumbers.length / 2); - if (sortedNumbers.length % 2 === 0) { - return sortedNumbers[middleIndex - 1] - .add(sortedNumbers[middleIndex]) - .div(new BN(2)); - } else { - return sortedNumbers[middleIndex]; - } -}; - -const bnMean = (numbers: BN[]): BN => { - let sum = new BN(0); - for (let i = 0; i < numbers.length; i++) { - sum = sum.add(numbers[i]); - } - return sum.div(new BN(numbers.length)); -}; - -const timedPromise = async (promise: T) => { - const start = Date.now(); - const promiseResult = await promise; - - return { - promiseTime: Date.now() - start, - promiseResult, - }; -}; - -const chunks = (array: readonly T[], size: number): T[][] => { - return new Array(Math.ceil(array.length / size)) - .fill(null) - .map((_, index) => index * size) - .map((begin) => array.slice(begin, begin + size)); -}; - -const getMultipleAccountsInfoChunked = async ( - connection: Connection, - accounts: PublicKey[] -): Promise<(AccountInfo | null)[]> => { - const accountChunks = chunks(accounts, 100); // 100 is limit for getMultipleAccountsInfo - const responses = await Promise.all( - accountChunks.map((chunk) => connection.getMultipleAccountsInfo(chunk)) - ); - return responses.flat(); -}; - -export const COMMON_UTILS = { - getIfVaultBalance, - getIfStakingVaultApr, - getCurrentOpenInterestForMarket, - getDepositAprForMarket, - getBorrowAprForMarket, - getTotalBorrowsForMarket, - getTotalDepositsForMarket, - dividesExactly, - toSnakeCase, - toCamelCase, - getTieredSortScore, - normalizeBaseAssetSymbol, - calculateZScore, - glueArray, - timedPromise, - chunks, - getMultipleAccountsInfoChunked, - MATH: { - NUM: { - mean: calculateMean, - median: calculateMedian, - }, - BN: { - bnMax, - bnMin, - bnMean, - bnMedian, - }, - }, -}; -export { getSwiftConfirmationTimeoutMs } from './signedMsgs'; -export { ResultSlotIncrementer } from './ResultSlotIncrementer'; -export { MultiplexWebSocket } from './MultiplexWebSocket'; +export * from './math'; +export * from './strings'; +export * from './enum'; +export * from './validation'; +export * from './token'; +export * from './trading'; +export * from './markets'; +export * from './orders'; +export * from './positions'; +export * from './accounts'; +export * from './core'; diff --git a/common-ts/src/utils/markets/balances.ts b/common-ts/src/utils/markets/balances.ts new file mode 100644 index 00000000..79aa2691 --- /dev/null +++ b/common-ts/src/utils/markets/balances.ts @@ -0,0 +1,71 @@ +import { + BigNum, + DriftClient, + getTokenAmount, + PRICE_PRECISION_EXP, + SpotBalanceType, + SpotMarketConfig, +} from '@drift-labs/sdk'; + +export const getTotalBorrowsForMarket = ( + market: SpotMarketConfig, + driftClient: DriftClient +) => { + const marketAccount = driftClient.getSpotMarketAccount(market.marketIndex); + + const totalBorrowsTokenAmount = getTokenAmount( + marketAccount.borrowBalance, + driftClient.getSpotMarketAccount(marketAccount.marketIndex), + SpotBalanceType.BORROW + ); + + const totalBorrowsAmountBigNum = BigNum.from( + totalBorrowsTokenAmount, + market.precisionExp + ); + + const priceData = driftClient.getOraclePriceDataAndSlot( + marketAccount.oracle, + marketAccount.oracleSource + ); + + const price = BigNum.from(priceData.data.price, PRICE_PRECISION_EXP); + + const totalBorrowsQuote = price.toNum() * totalBorrowsAmountBigNum.toNum(); + + return Number(totalBorrowsQuote.toFixed(2)); +}; + +export const getTotalDepositsForMarket = ( + market: SpotMarketConfig, + driftClient: DriftClient +) => { + const marketAccount = driftClient.getSpotMarketAccount(market.marketIndex); + + const totalDepositsTokenAmount = getTokenAmount( + marketAccount.depositBalance, + marketAccount, + SpotBalanceType.DEPOSIT + ); + + const totalDepositsTokenAmountBigNum = BigNum.from( + totalDepositsTokenAmount, + market.precisionExp + ); + + const priceData = driftClient.getOraclePriceDataAndSlot( + marketAccount.oracle, + marketAccount.oracleSource + ); + + const price = BigNum.from(priceData.data.price, PRICE_PRECISION_EXP); + + const totalDepositsBase = totalDepositsTokenAmountBigNum.toNum(); + const totalDepositsQuote = + price.toNum() * totalDepositsTokenAmountBigNum.toNum(); + + return { + totalDepositsBase, + totalDepositsQuote, + }; +}; diff --git a/common-ts/src/utils/markets/config.ts b/common-ts/src/utils/markets/config.ts new file mode 100644 index 00000000..90e5d3f8 --- /dev/null +++ b/common-ts/src/utils/markets/config.ts @@ -0,0 +1,45 @@ +import { + DriftEnv, + MarketType, + PerpMarketConfig, + PerpMarkets, + SpotMarketConfig, + SpotMarkets, +} from '@drift-labs/sdk'; +import { ENUM_UTILS } from '../enum'; + +const getBaseAssetSymbol = (marketName: string, removePrefix = false) => { + let baseAssetSymbol = marketName.replace('-PERP', '').replace('/USDC', ''); + + if (removePrefix) { + baseAssetSymbol = baseAssetSymbol.replace('1K', '').replace('1M', ''); + } + + return baseAssetSymbol; +}; + +function getMarketConfig( + driftEnv: DriftEnv, + marketType: typeof MarketType.PERP, + marketIndex: number +): PerpMarketConfig; +function getMarketConfig( + driftEnv: DriftEnv, + marketType: typeof MarketType.SPOT, + marketIndex: number +): SpotMarketConfig; +function getMarketConfig( + driftEnv: DriftEnv, + marketType: MarketType, + marketIndex: number +): PerpMarketConfig | SpotMarketConfig { + const isPerp = ENUM_UTILS.match(marketType, MarketType.PERP); + + if (isPerp) { + return PerpMarkets[driftEnv][marketIndex]; + } else { + return SpotMarkets[driftEnv][marketIndex]; + } +} + +export { getBaseAssetSymbol, getMarketConfig }; diff --git a/common-ts/src/utils/markets/index.ts b/common-ts/src/utils/markets/index.ts new file mode 100644 index 00000000..500ba8da --- /dev/null +++ b/common-ts/src/utils/markets/index.ts @@ -0,0 +1,6 @@ +export * from './config'; +export * from './leverage'; +export * from './operations'; +export * from './interest'; +export * from './balances'; +export * from './precisions'; diff --git a/common-ts/src/utils/markets/interest.ts b/common-ts/src/utils/markets/interest.ts new file mode 100644 index 00000000..aced019f --- /dev/null +++ b/common-ts/src/utils/markets/interest.ts @@ -0,0 +1,101 @@ +import { + BASE_PRECISION_EXP, + BigNum, + calculateBorrowRate, + calculateDepositRate, + DriftClient, + MarketType, + PRICE_PRECISION_EXP, + SPOT_MARKET_RATE_PRECISION_EXP, +} from '@drift-labs/sdk'; +import { ENUM_UTILS } from '../enum'; + +/** + * Returns the quote amount of the current open interest for a market, using the current oracle price + * @param marketIndex + * @param marketType + * @param driftClient + * @returns + */ +export const getCurrentOpenInterestForMarket = ( + marketIndex: number, + marketType: MarketType, + driftClient: DriftClient +) => { + if (ENUM_UTILS.match(marketType, MarketType.PERP)) { + const market = driftClient.getPerpMarketAccount(marketIndex); + const OI = BigNum.from( + market.amm.baseAssetAmountLong.add(market.amm.baseAssetAmountShort.abs()), + BASE_PRECISION_EXP + ); + + const priceData = driftClient.getOraclePriceDataAndSlot( + market.amm.oracle, + market.amm.oracleSource + ); + + const price = BigNum.from(priceData.data.price, PRICE_PRECISION_EXP); + + const quoteOIforMarket = price.toNum() * OI.toNum(); + + return quoteOIforMarket; + } else { + throw new Error('Invalid market type for Open Interest calculation'); + } +}; + +/** + * Gets the deposit APR for a spot market, in percent + * @param marketIndex + * @param marketType + * @param driftClient + * @returns + */ +export const getDepositAprForMarket = ( + marketIndex: number, + marketType: MarketType, + driftClient: DriftClient +) => { + if (ENUM_UTILS.match(marketType, MarketType.SPOT)) { + const marketAccount = driftClient.getSpotMarketAccount(marketIndex); + + const depositApr = BigNum.from( + calculateDepositRate(marketAccount), + SPOT_MARKET_RATE_PRECISION_EXP + ); + + const depositAprPct = depositApr.toNum() * 100; + + return depositAprPct; + } else { + throw new Error('Invalid market type for Deposit APR calculation'); + } +}; + +/** + * Get's the borrow APR for a spot market, in percent + * @param marketIndex + * @param marketType + * @param driftClient + * @returns + */ +export const getBorrowAprForMarket = ( + marketIndex: number, + marketType: MarketType, + driftClient: DriftClient +) => { + if (ENUM_UTILS.match(marketType, MarketType.SPOT)) { + const marketAccount = driftClient.getSpotMarketAccount(marketIndex); + + const depositApr = BigNum.from( + calculateBorrowRate(marketAccount), + SPOT_MARKET_RATE_PRECISION_EXP + ); + + const depositAprPct = depositApr.toNum() * 100; + + return depositAprPct; + } else { + throw new Error('Invalid market type for Borrow APR calculation'); + } +}; diff --git a/common-ts/src/utils/markets/leverage.ts b/common-ts/src/utils/markets/leverage.ts new file mode 100644 index 00000000..51596663 --- /dev/null +++ b/common-ts/src/utils/markets/leverage.ts @@ -0,0 +1,93 @@ +import { + DriftClient, + MarketType, + PerpMarketAccount, + SpotMarketAccount, +} from '@drift-labs/sdk'; +import { ENUM_UTILS } from '../enum'; +import { DEFAULT_MAX_MARKET_LEVERAGE } from '../../constants/markets'; + +const getMaxLeverageForMarketAccount = ( + marketType: MarketType, + marketAccount: PerpMarketAccount | SpotMarketAccount +): { + maxLeverage: number; + highLeverageMaxLeverage: number; + hasHighLeverage: boolean; +} => { + const isPerp = ENUM_UTILS.match(marketType, MarketType.PERP); + + try { + if (isPerp) { + const perpMarketAccount = marketAccount as PerpMarketAccount; + + const maxLeverage = parseFloat( + ( + 1 / + ((perpMarketAccount?.marginRatioInitial + ? perpMarketAccount.marginRatioInitial + : 10000 / DEFAULT_MAX_MARKET_LEVERAGE) / + 10000) + ).toFixed(2) + ); + + const marketHasHighLeverageMode = + !!perpMarketAccount?.highLeverageMarginRatioInitial; + + const highLeverageMaxLeverage = marketHasHighLeverageMode + ? parseFloat( + ( + 1 / + ((perpMarketAccount?.highLeverageMarginRatioInitial + ? perpMarketAccount?.highLeverageMarginRatioInitial + : 10000 / DEFAULT_MAX_MARKET_LEVERAGE) / + 10000) + ).toFixed(1) + ) + : 0; + + return { + maxLeverage, + highLeverageMaxLeverage, + hasHighLeverage: marketHasHighLeverageMode, + }; + } else { + const spotMarketAccount = marketAccount as SpotMarketAccount; + + const liabilityWeight = spotMarketAccount + ? spotMarketAccount.initialLiabilityWeight / 10000 + : 0; + + return { + maxLeverage: parseFloat((1 / (liabilityWeight - 1)).toFixed(2)), + highLeverageMaxLeverage: 0, + hasHighLeverage: false, + }; + } + } catch (e) { + console.error(e); + return { + maxLeverage: 0, + highLeverageMaxLeverage: 0, + hasHighLeverage: false, + }; + } +}; + +const getMaxLeverageForMarket = ( + marketType: MarketType, + marketIndex: number, + driftClient: DriftClient +): { + maxLeverage: number; + highLeverageMaxLeverage: number; + hasHighLeverage: boolean; +} => { + const marketAccount = ENUM_UTILS.match(marketType, MarketType.PERP) + ? driftClient.getPerpMarketAccount(marketIndex) + : driftClient.getSpotMarketAccount(marketIndex); + + return getMaxLeverageForMarketAccount(marketType, marketAccount); +}; + +export { getMaxLeverageForMarketAccount, getMaxLeverageForMarket }; diff --git a/common-ts/src/utils/markets/operations.ts b/common-ts/src/utils/markets/operations.ts new file mode 100644 index 00000000..ec35ff17 --- /dev/null +++ b/common-ts/src/utils/markets/operations.ts @@ -0,0 +1,87 @@ +import { + InsuranceFundOperation, + isOperationPaused, + PerpMarketAccount, + PerpOperation, + SpotMarketAccount, + SpotOperation, +} from '@drift-labs/sdk'; + +const PerpOperationsMap = { + UPDATE_FUNDING: 'Funding', + AMM_FILL: 'AMM Fills', + FILL: 'Fills', + SETTLE_PNL: 'Settle P&L', + SETTLE_PNL_WITH_POSITION: 'Settle P&L With Open Position', +}; + +const SpotOperationsMap = { + UPDATE_CUMULATIVE_INTEREST: 'Update Cumulative Interest', + FILL: 'Fills', + WITHDRAW: 'Withdrawals', +}; + +const InsuranceFundOperationsMap = { + INIT: 'Initialize IF', + ADD: 'Deposit To IF', + REQUEST_REMOVE: 'Request Withdrawal From IF', + REMOVE: 'Withdraw From IF', +}; + +const getPausedOperations = ( + marketAccount: PerpMarketAccount | SpotMarketAccount +): string[] => { + if (!marketAccount) return []; + + const pausedOperations = []; + //@ts-ignore + const isPerp = !!marketAccount.amm; + + // check perp operations + if (isPerp) { + Object.keys(PerpOperation) + .filter((operation) => + isOperationPaused( + marketAccount.pausedOperations, + PerpOperation[operation] + ) + ) + .forEach((pausedOperation) => { + pausedOperations.push(PerpOperationsMap[pausedOperation]); + }); + } else { + // check spot operations + Object.keys(SpotOperation) + .filter((operation) => + isOperationPaused( + marketAccount.pausedOperations, + SpotOperation[operation] + ) + ) + .forEach((pausedOperation) => { + pausedOperations.push(SpotOperationsMap[pausedOperation]); + }); + + // check IF operations + Object.keys(InsuranceFundOperation) + .filter((operation) => + isOperationPaused( + //@ts-ignore + marketAccount.ifPausedOperations, + InsuranceFundOperation[operation] + ) + ) + .forEach((pausedOperation) => { + pausedOperations.push(InsuranceFundOperationsMap[pausedOperation]); + }); + } + + return pausedOperations; +}; + +export { + PerpOperationsMap, + SpotOperationsMap, + InsuranceFundOperationsMap, + getPausedOperations, +}; diff --git a/common-ts/src/utils/math.ts b/common-ts/src/utils/math.ts deleted file mode 100644 index 4f8009c3..00000000 --- a/common-ts/src/utils/math.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { - BigNum, - BN, - L2OrderBook, - PERCENTAGE_PRECISION, - SpotMarketConfig, - ZERO, -} from '@drift-labs/sdk'; - -const calculateMarkPrice = ( - bestBidPrice?: BN, - bestAskPrice?: BN, - oraclePrice?: BN -) => { - const bid = bestBidPrice; - const ask = bestAskPrice; - - let mid: BN; - - // if bid/ask cross, force it to be the one closer to oracle, if oracle is in the middle, use oracle price - if (bid && ask && bid.gt(ask) && oraclePrice) { - if (bid.gt(oraclePrice) && ask.gt(oraclePrice)) { - mid = BN.min(bid, ask); - } else if (bid.lt(oraclePrice) && ask.lt(oraclePrice)) { - mid = BN.max(bid, ask); - } else { - mid = oraclePrice; - } - } else { - if (bid && ask) { - mid = bid.add(ask).divn(2); - } else if (oraclePrice) { - mid = oraclePrice; - } else { - mid = undefined; - } - } - - return mid; -}; - -const calculateBidAskAndmarkPrice = (l2: L2OrderBook, oraclePrice?: BN) => { - const bestBidPrice = l2.bids.reduce((previousMax, currentBid) => { - if (!previousMax) return currentBid.price; - return BN.max(currentBid.price, previousMax); - }, undefined as BN); - - const bestAskPrice = l2.asks.reduce((previousMin, currentBid) => { - if (!previousMin) return currentBid.price; - return BN.min(currentBid.price, previousMin); - }, undefined as BN); - - const markPrice = calculateMarkPrice(bestBidPrice, bestAskPrice, oraclePrice); - - return { - bestBidPrice, - bestAskPrice, - markPrice, - }; -}; - -const calculateSpreadQuote = (bestBidPrice: BN, bestAskPrice: BN) => { - return BN.max(bestAskPrice.sub(bestBidPrice), ZERO); -}; - -function calculateSpreadPct(markPricePrice: BN, spreadQuote: BN) { - return spreadQuote.muln(100).mul(PERCENTAGE_PRECISION).div(markPricePrice); -} - -const calculateSpread = (bestBidPrice: BN, bestAskPrice: BN, markPrice: BN) => { - const spreadQuote = calculateSpreadQuote(bestBidPrice, bestAskPrice); - const spreadPct = calculateSpreadPct(markPrice, spreadQuote); - - return { - spreadPct, - spreadQuote, - }; -}; - -const calculateSpreadBidAskMark = ( - l2: Pick, - oraclePrice?: BN -) => { - if (l2.asks.length === 0 || l2.bids.length === 0) { - return { - spreadQuote: undefined, - spreadPct: undefined, - markPrice: undefined, - bestBidPrice: undefined, - bestAskPrice: undefined, - }; - } - - const { bestBidPrice, bestAskPrice, markPrice } = calculateBidAskAndmarkPrice( - l2, - oraclePrice - ); - - const { spreadPct, spreadQuote } = calculateSpread( - bestBidPrice, - bestAskPrice, - markPrice - ); - return { - bestBidPrice, - bestAskPrice, - markPrice, - spreadPct, - spreadQuote, - }; -}; - -export const TRADE_PRECISION = 6; - -export const getPctCompletion = ( - start: number, - end: number, - current: number -) => { - const totalProgressSize = end - start; - const currentProgressSize = current - start; - - return (currentProgressSize / totalProgressSize) * 100; -}; - -export const sortBnAsc = (bnA: BN, bnB: BN) => { - if (bnA.gt(bnB)) return 1; - if (bnA.eq(bnB)) return 0; - if (bnA.lt(bnB)) return -1; - - return 0; -}; - -export const sortBnDesc = (bnA: BN, bnB: BN) => sortBnAsc(bnB, bnA); - -export const getBigNumRoundedToStepSize = (baseSize: BigNum, stepSize: BN) => { - const baseSizeRounded = baseSize.div(stepSize).mul(stepSize); - return baseSizeRounded; -}; - -export const truncateInputToPrecision = ( - input: string, - marketPrecisionExp: SpotMarketConfig['precisionExp'] -) => { - const decimalPlaces = input.split('.')[1]?.length ?? 0; - const maxDecimals = marketPrecisionExp.toNumber(); - - if (decimalPlaces > maxDecimals) { - return input.slice(0, input.length - (decimalPlaces - maxDecimals)); - } - - return input; -}; - -export const roundToStepSize = (value: string, stepSize?: number) => { - const stepSizeExp = stepSize?.toString().split('.')[1]?.length ?? 0; - const truncatedValue = truncateInputToPrecision(value, new BN(stepSizeExp)); - - if (truncatedValue.charAt(truncatedValue.length - 1) === '.') { - return truncatedValue.slice(0, -1); - } - - return truncatedValue; -}; - -export const roundToStepSizeIfLargeEnough = ( - value: string, - stepSize?: number -) => { - const parsedValue = parseFloat(value); - if (isNaN(parsedValue) || stepSize === 0 || !value || parsedValue === 0) { - return value; - } - - return roundToStepSize(value, stepSize); -}; - -export const valueIsBelowStepSize = (value: string, stepSize: number) => { - const parsedValue = parseFloat(value); - - if (isNaN(parsedValue)) return false; - - return parsedValue < stepSize; -}; - -/** - * NOTE: Do not use modulo alone to check if numbers fit evenly. - * Due to floating point precision issues this can return incorrect results. - * i.e. 5.1 % 0.1 = 0.09999999999999959 (should be 0) - * tells me 5.1 / 0.1 = 50.99999999999999 - */ -export const numbersFitEvenly = ( - numberOne: number, - numberTwo: number -): boolean => { - if (isNaN(numberOne) || isNaN(numberTwo)) return false; - if (numberOne === 0 || numberTwo === 0) return true; - - return ( - Number.isInteger(Number((numberOne / numberTwo).toFixed(9))) || - numberOne % numberTwo === 0 - ); -}; - -export function roundToDecimal( - value: number, - decimals: number | undefined | null -) { - return decimals ? Math.round(value * 10 ** decimals) / 10 ** decimals : value; -} - -export const roundBigNumToDecimalPlace = ( - bignum: BigNum, - decimalPlaces: number -): BigNum => { - const factor = Math.pow(10, decimalPlaces); - const newNum = Math.round(bignum.toNum() * factor) / factor; - return BigNum.fromPrint(newNum.toString(), bignum.precision); -}; - -export const sortRecordsByTs = ( - records: T | undefined, - direction: 'asc' | 'desc' = 'desc' -) => { - if (!records || !records?.length) return []; - - return direction === 'desc' - ? [...records].sort((a, b) => b.ts.toNumber() - a.ts.toNumber()) - : [...records].sort((a, b) => a.ts.toNumber() - b.ts.toNumber()); -}; - -export const COMMON_MATH = { - calculateSpreadBidAskMark, -}; diff --git a/common-ts/src/utils/math/bignum.ts b/common-ts/src/utils/math/bignum.ts new file mode 100644 index 00000000..e8f13de9 --- /dev/null +++ b/common-ts/src/utils/math/bignum.ts @@ -0,0 +1,15 @@ +import { BigNum, BN } from '@drift-labs/sdk'; + +export const roundBigNumToDecimalPlace = ( + bignum: BigNum, + decimalPlaces: number +): BigNum => { + const factor = Math.pow(10, decimalPlaces); + const newNum = Math.round(bignum.toNum() * factor) / factor; + return BigNum.fromPrint(newNum.toString(), bignum.precision); +}; + +export const getBigNumRoundedToStepSize = (baseSize: BigNum, stepSize: BN) => { + const baseSizeRounded = baseSize.div(stepSize).mul(stepSize); + return baseSizeRounded; +}; diff --git a/common-ts/src/utils/math/bn.ts b/common-ts/src/utils/math/bn.ts new file mode 100644 index 00000000..e15b1bc4 --- /dev/null +++ b/common-ts/src/utils/math/bn.ts @@ -0,0 +1,51 @@ +import { BN } from '@drift-labs/sdk'; + +export const bnMin = (numbers: BN[]): BN => { + let min = numbers[0]; + for (let i = 1; i < numbers.length; i++) { + if (numbers[i].lt(min)) { + min = numbers[i]; + } + } + return min; +}; + +export const bnMax = (numbers: BN[]): BN => { + let max = numbers[0]; + for (let i = 1; i < numbers.length; i++) { + if (numbers[i].gt(max)) { + max = numbers[i]; + } + } + return max; +}; + +export const bnMedian = (numbers: BN[]): BN => { + const sortedNumbers = numbers.sort((a, b) => a.cmp(b)); + const middleIndex = Math.floor(sortedNumbers.length / 2); + if (sortedNumbers.length % 2 === 0) { + return sortedNumbers[middleIndex - 1] + .add(sortedNumbers[middleIndex]) + .div(new BN(2)); + } else { + return sortedNumbers[middleIndex]; + } +}; + +export const bnMean = (numbers: BN[]): BN => { + let sum = new BN(0); + for (let i = 0; i < numbers.length; i++) { + sum = sum.add(numbers[i]); + } + return sum.div(new BN(numbers.length)); +}; + +export const sortBnAsc = (bnA: BN, bnB: BN) => { + if (bnA.gt(bnB)) return 1; + if (bnA.eq(bnB)) return 0; + if (bnA.lt(bnB)) return -1; + + return 0; +}; + +export const sortBnDesc = (bnA: BN, bnB: BN) => sortBnAsc(bnB, bnA); diff --git a/common-ts/src/utils/math/index.ts b/common-ts/src/utils/math/index.ts new file mode 100644 index 00000000..e085d98f --- /dev/null +++ b/common-ts/src/utils/math/index.ts @@ -0,0 +1,7 @@ +export * from './numbers'; +export * from './bn'; +export * from './bignum'; +export * from './precision'; +export * from './spread'; +export * from './price'; +export * from './sort'; diff --git a/common-ts/src/utils/math/numbers.ts b/common-ts/src/utils/math/numbers.ts new file mode 100644 index 00000000..f1d9f46f --- /dev/null +++ b/common-ts/src/utils/math/numbers.ts @@ -0,0 +1,73 @@ +export const aprFromApy = (apy: number, compoundsPerYear: number) => { + const compoundedAmount = 1 + apy * 0.01; + const estimatedApr = + (Math.pow(compoundedAmount, 1 / compoundsPerYear) - 1) * compoundsPerYear; + + return estimatedApr * 100; +}; + +export const calculateMean = (numbers: number[]): number => { + const sum = numbers.reduce((total, num) => total + num, 0); + return sum / numbers.length; +}; + +export const calculateMedian = (numbers: number[]): number => { + const sortedNumbers = numbers.sort(); + const middleIndex = Math.floor(sortedNumbers.length / 2); + if (sortedNumbers.length % 2 === 0) { + return (sortedNumbers[middleIndex - 1] + sortedNumbers[middleIndex]) / 2; + } else { + return sortedNumbers[middleIndex]; + } +}; + +export const calculateStandardDeviation = ( + numbers: number[], + mean: number +): number => { + const squaredDifferences = numbers.map((num) => Math.pow(num - mean, 2)); + const sumSquaredDifferences = squaredDifferences.reduce( + (total, diff) => total + diff, + 0 + ); + const variance = sumSquaredDifferences / numbers.length; + return Math.sqrt(variance); +}; + +/** + * Returns the number of standard deviations between a target value and the history of values to compare it to. + * @param target + * @param previousValues + * @returns + */ +export const calculateZScore = ( + target: number, + previousValues: number[] +): number => { + const meanValue = calculateMean(previousValues); + const standardDeviationValue = calculateStandardDeviation( + previousValues, + meanValue + ); + + const zScore = (target - meanValue) / standardDeviationValue; + return zScore; +}; + +export const getPctCompletion = ( + start: number, + end: number, + current: number +) => { + const totalProgressSize = end - start; + const currentProgressSize = current - start; + + return (currentProgressSize / totalProgressSize) * 100; +}; + +export function roundToDecimal( + value: number, + decimals: number | undefined | null +) { + return decimals ? Math.round(value * 10 ** decimals) / 10 ** decimals : value; +} diff --git a/common-ts/src/utils/math/precision.ts b/common-ts/src/utils/math/precision.ts new file mode 100644 index 00000000..7b6b6593 --- /dev/null +++ b/common-ts/src/utils/math/precision.ts @@ -0,0 +1,85 @@ +import { BN, SpotMarketConfig } from '@drift-labs/sdk'; + +export const TRADE_PRECISION = 6; + +export const truncateInputToPrecision = ( + input: string, + marketPrecisionExp: SpotMarketConfig['precisionExp'] +) => { + const decimalPlaces = input.split('.')[1]?.length ?? 0; + const maxDecimals = marketPrecisionExp.toNumber(); + + if (decimalPlaces > maxDecimals) { + return input.slice(0, input.length - (decimalPlaces - maxDecimals)); + } + + return input; +}; + +export const roundToStepSize = (value: string, stepSize?: number) => { + const stepSizeExp = stepSize?.toString().split('.')[1]?.length ?? 0; + const truncatedValue = truncateInputToPrecision(value, new BN(stepSizeExp)); + + if (truncatedValue.charAt(truncatedValue.length - 1) === '.') { + return truncatedValue.slice(0, -1); + } + + return truncatedValue; +}; + +export const roundToStepSizeIfLargeEnough = ( + value: string, + stepSize?: number +) => { + const parsedValue = parseFloat(value); + if (isNaN(parsedValue) || stepSize === 0 || !value || parsedValue === 0) { + return value; + } + + return roundToStepSize(value, stepSize); +}; + +export const valueIsBelowStepSize = (value: string, stepSize: number) => { + const parsedValue = parseFloat(value); + + if (isNaN(parsedValue)) return false; + + return parsedValue < stepSize; +}; + +/** + * NOTE: Do not use modulo alone to check if numbers fit evenly. + * Due to floating point precision issues this can return incorrect results. + * i.e. 5.1 % 0.1 = 0.09999999999999959 (should be 0) + * tells me 5.1 / 0.1 = 50.99999999999999 + */ +export const numbersFitEvenly = ( + numberOne: number, + numberTwo: number +): boolean => { + if (isNaN(numberOne) || isNaN(numberTwo)) return false; + if (numberOne === 0 || numberTwo === 0) return true; + + return ( + Number.isInteger(Number((numberOne / numberTwo).toFixed(9))) || + numberOne % numberTwo === 0 + ); +}; + +/** + * Check if numbers divide exactly, accounting for floating point division annoyingness + * @param numerator + * @param denominator + * @returns + */ +export const dividesExactly = (numerator: number, denominator: number) => { + const division = numerator / denominator; + const remainder = division % 1; + + if (remainder === 0) return true; + + // Because of floating point weirdness, we're just going to assume that if the remainder after dividing is less than 1/10^6 then the numbers do divide exactly + if (Math.abs(remainder - 1) < 1 / 10 ** 6) return true; + + return false; +}; diff --git a/common-ts/src/utils/math/price.ts b/common-ts/src/utils/math/price.ts new file mode 100644 index 00000000..aa5618fd --- /dev/null +++ b/common-ts/src/utils/math/price.ts @@ -0,0 +1,73 @@ +import { + AMM_RESERVE_PRECISION_EXP, + AMM_TO_QUOTE_PRECISION_RATIO, + BASE_PRECISION, + BASE_PRECISION_EXP, + BigNum, + BN, + OrderActionRecord, + PRICE_PRECISION, + PRICE_PRECISION_EXP, + QUOTE_PRECISION, +} from '@drift-labs/sdk'; +import { UISerializableOrderActionRecord } from '../../serializableTypes'; + +/** + * Returns the average price for a given base amount and quote amount. + * @param quoteAmount + * @param baseAmount + * @returns PRICE_PRECISION + */ +export const getPriceForBaseAndQuoteAmount = ( + quoteAmount: BN, + baseAmount: BN +) => { + return quoteAmount + .mul(PRICE_PRECISION) + .mul(BASE_PRECISION) + .div(QUOTE_PRECISION) + .div(BigNum.from(baseAmount, BASE_PRECISION_EXP).val); +}; + +export const getPriceForOrderRecord = ( + orderRecord: Pick< + OrderActionRecord, + 'quoteAssetAmountFilled' | 'baseAssetAmountFilled' + > +) => { + return getPriceForBaseAndQuoteAmount( + // @ts-ignore + orderRecord.quoteAssetAmountFilled, + // @ts-ignore + orderRecord.baseAssetAmountFilled + ); +}; + +export const getPriceForUIOrderRecord = ( + orderRecord: Pick< + UISerializableOrderActionRecord, + 'quoteAssetAmountFilled' | 'baseAssetAmountFilled' + > +) => { + return orderRecord.quoteAssetAmountFilled + .shiftTo(AMM_RESERVE_PRECISION_EXP) + .shift(PRICE_PRECISION_EXP) + .div(orderRecord.baseAssetAmountFilled.shiftTo(BASE_PRECISION_EXP)) + .shiftTo(PRICE_PRECISION_EXP); +}; + +export const calculateAverageEntryPrice = ( + quoteAssetAmount: BigNum, + baseAssetAmount: BigNum +): BigNum => { + if (baseAssetAmount.eqZero()) return BigNum.zero(); + + return BigNum.from( + quoteAssetAmount.val + .mul(PRICE_PRECISION) + .mul(AMM_TO_QUOTE_PRECISION_RATIO) + .div(baseAssetAmount.shiftTo(BASE_PRECISION_EXP).val) + .abs(), + PRICE_PRECISION_EXP + ); +}; diff --git a/common-ts/src/utils/math/sort.ts b/common-ts/src/utils/math/sort.ts new file mode 100644 index 00000000..88b52355 --- /dev/null +++ b/common-ts/src/utils/math/sort.ts @@ -0,0 +1,34 @@ +import { BN } from '@drift-labs/sdk'; + +/** + * Helper utility to get a sort score for "tiered" parameters. + * + * Example: Want to sort students by Grade, but fall back to using Age if they are equal. This method will accept an array for each student of [grade, age] and return the appropriate sort score for each. + * + * @param aScores + * @param bScores + * @returns + */ +export const getTieredSortScore = (aScores: number[], bScores: number[]) => { + const maxIndex = Math.max(aScores.length, bScores.length); + + for (let i = 0; i < maxIndex; i++) { + const aScore = aScores[i] ?? Number.MIN_SAFE_INTEGER; + const bScore = bScores[i] ?? Number.MIN_SAFE_INTEGER; + + if (aScore !== bScore) return aScore - bScore; + } + + return 0; +}; + +export const sortRecordsByTs = ( + records: T | undefined, + direction: 'asc' | 'desc' = 'desc' +) => { + if (!records || !records?.length) return []; + + return direction === 'desc' + ? [...records].sort((a, b) => b.ts.toNumber() - a.ts.toNumber()) + : [...records].sort((a, b) => a.ts.toNumber() - b.ts.toNumber()); +}; diff --git a/common-ts/src/utils/math/spread.ts b/common-ts/src/utils/math/spread.ts new file mode 100644 index 00000000..2d634a64 --- /dev/null +++ b/common-ts/src/utils/math/spread.ts @@ -0,0 +1,104 @@ +import { BN, L2OrderBook, PERCENTAGE_PRECISION, ZERO } from '@drift-labs/sdk'; + +const calculateMarkPrice = ( + bestBidPrice?: BN, + bestAskPrice?: BN, + oraclePrice?: BN +) => { + const bid = bestBidPrice; + const ask = bestAskPrice; + + let mid: BN; + + // if bid/ask cross, force it to be the one closer to oracle, if oracle is in the middle, use oracle price + if (bid && ask && bid.gt(ask) && oraclePrice) { + if (bid.gt(oraclePrice) && ask.gt(oraclePrice)) { + mid = BN.min(bid, ask); + } else if (bid.lt(oraclePrice) && ask.lt(oraclePrice)) { + mid = BN.max(bid, ask); + } else { + mid = oraclePrice; + } + } else { + if (bid && ask) { + mid = bid.add(ask).divn(2); + } else if (oraclePrice) { + mid = oraclePrice; + } else { + mid = undefined; + } + } + + return mid; +}; + +const calculateBidAskAndmarkPrice = (l2: L2OrderBook, oraclePrice?: BN) => { + const bestBidPrice = l2.bids.reduce((previousMax, currentBid) => { + if (!previousMax) return currentBid.price; + return BN.max(currentBid.price, previousMax); + }, undefined as BN); + + const bestAskPrice = l2.asks.reduce((previousMin, currentBid) => { + if (!previousMin) return currentBid.price; + return BN.min(currentBid.price, previousMin); + }, undefined as BN); + + const markPrice = calculateMarkPrice(bestBidPrice, bestAskPrice, oraclePrice); + + return { + bestBidPrice, + bestAskPrice, + markPrice, + }; +}; + +const calculateSpreadQuote = (bestBidPrice: BN, bestAskPrice: BN) => { + return BN.max(bestAskPrice.sub(bestBidPrice), ZERO); +}; + +function calculateSpreadPct(markPricePrice: BN, spreadQuote: BN) { + return spreadQuote.muln(100).mul(PERCENTAGE_PRECISION).div(markPricePrice); +} + +const calculateSpread = (bestBidPrice: BN, bestAskPrice: BN, markPrice: BN) => { + const spreadQuote = calculateSpreadQuote(bestBidPrice, bestAskPrice); + const spreadPct = calculateSpreadPct(markPrice, spreadQuote); + + return { + spreadPct, + spreadQuote, + }; +}; + +export const calculateSpreadBidAskMark = ( + l2: Pick, + oraclePrice?: BN +) => { + if (l2.asks.length === 0 || l2.bids.length === 0) { + return { + spreadQuote: undefined, + spreadPct: undefined, + markPrice: undefined, + bestBidPrice: undefined, + bestAskPrice: undefined, + }; + } + + const { bestBidPrice, bestAskPrice, markPrice } = calculateBidAskAndmarkPrice( + l2, + oraclePrice + ); + + const { spreadPct, spreadQuote } = calculateSpread( + bestBidPrice, + bestAskPrice, + markPrice + ); + return { + bestBidPrice, + bestAskPrice, + markPrice, + spreadPct, + spreadQuote, + }; +}; diff --git a/common-ts/src/utils/orderbook/index.ts b/common-ts/src/utils/orderbook/index.ts index b1c2d3dd..494c7a6d 100644 --- a/common-ts/src/utils/orderbook/index.ts +++ b/common-ts/src/utils/orderbook/index.ts @@ -9,7 +9,7 @@ import { groupL2, } from '@drift-labs/sdk'; import { MarketId } from '../../types'; -import { COMMON_MATH } from '../math'; +import { calculateSpreadBidAskMark } from '../math'; import { BidsAndAsks, CategorisedLiquidity, @@ -22,7 +22,7 @@ import { OrderBookDisplayStateBidAsk, RawL2Output, } from './types'; -import { COMMON_UTILS } from '..'; +import { dividesExactly } from '../math'; import { EMPTY_ORDERBOOK_ROW } from './constants'; export * from './types'; @@ -163,10 +163,7 @@ export const calculateDynamicSlippageFromL2 = ({ : DEFAULT_DYNAMIC_SLIPPAGE_CONFIG; // Calculate spread information from L2 data using the oracle price - const spreadBidAskMark = COMMON_MATH.calculateSpreadBidAskMark( - l2Data, - oraclePrice - ); + const spreadBidAskMark = calculateSpreadBidAskMark(l2Data, oraclePrice); const bestAskNum = spreadBidAskMark.bestAskPrice?.toNumber?.() || 0; const bestBidNum = spreadBidAskMark.bestBidPrice?.toNumber?.() || 0; @@ -286,7 +283,7 @@ export const getBucketFloorForPrice = ( const _groupingSize = groupingSize as unknown as number; - if (COMMON_UTILS.dividesExactly(price, _groupingSize)) { + if (dividesExactly(price, _groupingSize)) { return roundForOrderbook(price); } @@ -345,7 +342,7 @@ export const getBucketAnchorPrice = ( groupingSize: GroupingSizeQuoteValue ) => { // If the grouping size matches exactly then the anchor price should be the same as the floor price regardless - if (COMMON_UTILS.dividesExactly(price, groupingSize as unknown as number)) { + if (dividesExactly(price, groupingSize as unknown as number)) { return getBucketFloorForPrice(price, groupingSize); } diff --git a/common-ts/src/utils/orders/filters.ts b/common-ts/src/utils/orders/filters.ts new file mode 100644 index 00000000..371b9c60 --- /dev/null +++ b/common-ts/src/utils/orders/filters.ts @@ -0,0 +1,63 @@ +import { + OrderAction, + OrderActionRecord, + OrderStatus, + OrderTriggerCondition, + OrderType, + ZERO, +} from '@drift-labs/sdk'; +import { + UISerializableOrder, + UISerializableOrderActionRecord, +} from '../../serializableTypes'; +import { matchEnum, ENUM_UTILS } from '../enum'; + +// Trade records are order records which have been filled +export const orderActionRecordIsTrade = (orderRecord: OrderActionRecord) => + orderRecord.baseAssetAmountFilled.gt(ZERO) && + // @ts-ignore + matchEnum(orderRecord.action, OrderAction.FILL) && + true; + +export const uiOrderActionRecordIsTrade = ( + orderRecord: UISerializableOrderActionRecord +) => + orderRecord.baseAssetAmountFilled.gtZero() && + matchEnum(orderRecord.action, OrderAction.FILL) && + true; + +// Trade records are order records which have been filled +export const filterTradeRecordsFromOrderActionRecords = ( + orderRecords: OrderActionRecord[] +): OrderActionRecord[] => orderRecords.filter(orderActionRecordIsTrade); + +// Trade records are order records which have been filled +export const filterTradeRecordsFromUIOrderRecords = ( + orderRecords: UISerializableOrderActionRecord[] +): UISerializableOrderActionRecord[] => + orderRecords.filter(uiOrderActionRecordIsTrade); + +export const isOrderTriggered = ( + order: Pick +): boolean => { + const isTriggerOrderType = + ENUM_UTILS.match(order.orderType, OrderType.TRIGGER_MARKET) || + ENUM_UTILS.match(order.orderType, OrderType.TRIGGER_LIMIT); + + const orderWasCancelled = ENUM_UTILS.match( + order.status, + OrderStatus.CANCELED + ); + + const orderWasTriggered = + ENUM_UTILS.match( + order.triggerCondition, + OrderTriggerCondition.TRIGGERED_ABOVE + ) || + ENUM_UTILS.match( + order.triggerCondition, + OrderTriggerCondition.TRIGGERED_BELOW + ); + + return isTriggerOrderType && orderWasTriggered && !orderWasCancelled; +}; diff --git a/common-ts/src/utils/orders/flags.ts b/common-ts/src/utils/orders/flags.ts new file mode 100644 index 00000000..505ea55f --- /dev/null +++ b/common-ts/src/utils/orders/flags.ts @@ -0,0 +1,81 @@ +import { + BN, + ContractTier, + DriftClient, + isOneOfVariant, + MarketType, + OrderParamsBitFlag, + PERCENTAGE_PRECISION, + User, +} from '@drift-labs/sdk'; +import { getMaxLeverageForMarket } from '../markets/leverage'; + +export type HighLeverageOptions = { + numOfOpenHighLeverageSpots?: number; // if not provided, the method will assume that there is spots open + enterHighLeverageModeBufferPct?: number; +}; + +export function getPerpAuctionDuration( + priceDiff: BN, + price: BN, + contractTier: ContractTier +): number { + const percentDiff = priceDiff.mul(PERCENTAGE_PRECISION).div(price); + + const slotsPerBp = isOneOfVariant(contractTier, ['a', 'b']) + ? new BN(100) + : new BN(60); + + const rawSlots = percentDiff + .mul(slotsPerBp) + .div(PERCENTAGE_PRECISION.divn(100)); + + const clamped = BN.min(BN.max(rawSlots, new BN(5)), new BN(180)); + + return clamped.toNumber(); +} + +/** + * Mainly checks if the user will be entering high leverage mode through this order. + */ +export function getPerpOrderParamsBitFlags( + marketIndex: number, + driftClient: DriftClient, + userAccount: User, + attemptedLeverage: number, + highLeverageOptions?: { + numOfOpenHighLeverageSpots?: number; // if not provided, the method will assume that there is spots open + } +): number | undefined { + if (attemptedLeverage < 5) { + // there is no high leverage mode leverage that is higher than 4 + return undefined; + } + + const { numOfOpenHighLeverageSpots = Number.MAX_SAFE_INTEGER } = + highLeverageOptions || {}; + + if (numOfOpenHighLeverageSpots <= 0) { + return undefined; + } + + const { + hasHighLeverage: isMarketAHighLeverageMarket, + maxLeverage: regularMaxLeverage, + } = getMaxLeverageForMarket(MarketType.PERP, marketIndex, driftClient); + + if (!isMarketAHighLeverageMarket) { + return undefined; + } + + // Check if user is already in high leverage mode + if (userAccount.isHighLeverageMode('Initial')) { + return undefined; + } + + if (attemptedLeverage > regularMaxLeverage) { + return OrderParamsBitFlag.UpdateHighLeverageMode; + } + + return undefined; +} diff --git a/common-ts/src/utils/orders/index.ts b/common-ts/src/utils/orders/index.ts new file mode 100644 index 00000000..1346fb96 --- /dev/null +++ b/common-ts/src/utils/orders/index.ts @@ -0,0 +1,6 @@ +export * from './sort'; +export * from './filters'; +export * from './labels'; +export * from './oracle'; +export * from './flags'; +export * from './misc'; diff --git a/common-ts/src/common-ui-utils/order.ts b/common-ts/src/utils/orders/labels.ts similarity index 54% rename from common-ts/src/common-ui-utils/order.ts rename to common-ts/src/utils/orders/labels.ts index 46c1399e..9909c208 100644 --- a/common-ts/src/common-ui-utils/order.ts +++ b/common-ts/src/utils/orders/labels.ts @@ -1,18 +1,9 @@ import { OrderType, OrderTriggerCondition, - OrderStatus, PositionDirection, BigNum, ZERO, - ContractTier, - BN, - PERCENTAGE_PRECISION, - isOneOfVariant, - MarketType, - DriftClient, - User, - OrderParamsBitFlag, } from '@drift-labs/sdk'; import { LIMIT_ORDER_TYPE_CONFIG, @@ -24,14 +15,11 @@ import { TAKE_PROFIT_LIMIT_ORDER_TYPE_CONFIG, TAKE_PROFIT_MARKET_ORDER_TYPE_CONFIG, UI_ORDER_TYPES, -} from '../constants/orders'; -import { UISerializableOrder } from '../serializableTypes'; -import { ENUM_UTILS, matchEnum } from '../utils'; -import { AuctionParams } from '../types'; -import { EMPTY_AUCTION_PARAMS } from '../constants/trade'; -import { MARKET_UTILS } from './market'; +} from '../../constants/orders'; +import { UISerializableOrder } from '../../serializableTypes'; +import { ENUM_UTILS, matchEnum } from '../enum'; -const getOrderLabelFromOrderDetails = ( +export const getOrderLabelFromOrderDetails = ( orderDetails: Pick< UISerializableOrder, | 'orderType' @@ -127,32 +115,7 @@ const getOrderLabelFromOrderDetails = ( return '-'; }; -const getLimitPriceFromOracleOffset = ( - order: UISerializableOrder, - oraclePrice: BigNum -): BigNum => { - if ( - (order.price && !order.price.eqZero()) || - !order.oraclePriceOffset || - order.oraclePriceOffset.eqZero() || - !oraclePrice || - oraclePrice?.eqZero() - ) { - return order.price; - } - return oraclePrice.add(order.oraclePriceOffset); -}; - -function isAuctionEmpty(auctionParams: AuctionParams) { - return ( - auctionParams.auctionStartPrice === - EMPTY_AUCTION_PARAMS.auctionStartPrice && - auctionParams.auctionEndPrice === EMPTY_AUCTION_PARAMS.auctionEndPrice && - auctionParams.auctionDuration === EMPTY_AUCTION_PARAMS.auctionDuration - ); -} - -const getUIOrderTypeFromSdkOrderType = ( +export const getUIOrderTypeFromSdkOrderType = ( orderType: OrderType, triggerCondition: OrderTriggerCondition, direction: PositionDirection, @@ -213,112 +176,3 @@ const getUIOrderTypeFromSdkOrderType = ( } throw new Error('Invalid order type'); }; - -function getPerpAuctionDuration( - priceDiff: BN, - price: BN, - contractTier: ContractTier -): number { - const percentDiff = priceDiff.mul(PERCENTAGE_PRECISION).div(price); - - const slotsPerBp = isOneOfVariant(contractTier, ['a', 'b']) - ? new BN(100) - : new BN(60); - - const rawSlots = percentDiff - .mul(slotsPerBp) - .div(PERCENTAGE_PRECISION.divn(100)); - - const clamped = BN.min(BN.max(rawSlots, new BN(5)), new BN(180)); - - return clamped.toNumber(); -} - -export type HighLeverageOptions = { - numOfOpenHighLeverageSpots?: number; // if not provided, the method will assume that there is spots open - enterHighLeverageModeBufferPct?: number; -}; - -/** - * Mainly checks if the user will be entering high leverage mode through this order. - */ -function getPerpOrderParamsBitFlags( - marketIndex: number, - driftClient: DriftClient, - userAccount: User, - attemptedLeverage: number, - highLeverageOptions?: { - numOfOpenHighLeverageSpots?: number; // if not provided, the method will assume that there is spots open - } -): number | undefined { - if (attemptedLeverage < 5) { - // there is no high leverage mode leverage that is higher than 4 - return undefined; - } - - const { numOfOpenHighLeverageSpots = Number.MAX_SAFE_INTEGER } = - highLeverageOptions || {}; - - if (numOfOpenHighLeverageSpots <= 0) { - return undefined; - } - - const { - hasHighLeverage: isMarketAHighLeverageMarket, - maxLeverage: regularMaxLeverage, - } = MARKET_UTILS.getMaxLeverageForMarket( - MarketType.PERP, - marketIndex, - driftClient - ); - - if (!isMarketAHighLeverageMarket) { - return undefined; - } - - // Check if user is already in high leverage mode - if (userAccount.isHighLeverageMode('Initial')) { - return undefined; - } - - if (attemptedLeverage > regularMaxLeverage) { - return OrderParamsBitFlag.UpdateHighLeverageMode; - } - - return undefined; -} - -const isOrderTriggered = ( - order: Pick -): boolean => { - const isTriggerOrderType = - ENUM_UTILS.match(order.orderType, OrderType.TRIGGER_MARKET) || - ENUM_UTILS.match(order.orderType, OrderType.TRIGGER_LIMIT); - - const orderWasCancelled = ENUM_UTILS.match( - order.status, - OrderStatus.CANCELED - ); - - const orderWasTriggered = - ENUM_UTILS.match( - order.triggerCondition, - OrderTriggerCondition.TRIGGERED_ABOVE - ) || - ENUM_UTILS.match( - order.triggerCondition, - OrderTriggerCondition.TRIGGERED_BELOW - ); - - return isTriggerOrderType && orderWasTriggered && !orderWasCancelled; -}; - -export const ORDER_COMMON_UTILS = { - getOrderLabelFromOrderDetails, - getLimitPriceFromOracleOffset, - isAuctionEmpty, - getUIOrderTypeFromSdkOrderType, - getPerpAuctionDuration, - getPerpOrderParamsBitFlags, - isOrderTriggered, -}; diff --git a/common-ts/src/utils/orders/misc.ts b/common-ts/src/utils/orders/misc.ts new file mode 100644 index 00000000..6da8f5da --- /dev/null +++ b/common-ts/src/utils/orders/misc.ts @@ -0,0 +1,31 @@ +import { Event, OrderActionRecord } from '@drift-labs/sdk'; +import { UISerializableOrderActionRecord } from '../../serializableTypes'; +import { PartialUISerializableOrderActionRecord } from './sort'; + +export const orderIsNull = ( + order: UISerializableOrderActionRecord | Event, + side: 'taker' | 'maker' +) => { + return side === 'taker' ? !order.taker : !order.maker; +}; + +export const getTradeInfoFromActionRecord = ( + actionRecord: PartialUISerializableOrderActionRecord +) => { + return { + ts: actionRecord.ts, + baseAssetAmount: actionRecord.taker + ? actionRecord.takerOrderBaseAssetAmount + : actionRecord.makerOrderBaseAssetAmount, + baseAssetAmountFilled: actionRecord.taker + ? actionRecord.takerOrderCumulativeBaseAssetAmountFilled + : actionRecord.makerOrderCumulativeBaseAssetAmountFilled, + quoteAssetAmountFilled: actionRecord.taker + ? actionRecord.takerOrderCumulativeQuoteAssetAmountFilled + : actionRecord.makerOrderCumulativeQuoteAssetAmountFilled, + }; +}; + +export const getAnchorEnumString = (enumVal: Record) => { + return Object.keys(enumVal)[0]; +}; diff --git a/common-ts/src/utils/orders/oracle.ts b/common-ts/src/utils/orders/oracle.ts new file mode 100644 index 00000000..02c45de2 --- /dev/null +++ b/common-ts/src/utils/orders/oracle.ts @@ -0,0 +1,29 @@ +import { BigNum } from '@drift-labs/sdk'; +import { AuctionParams } from '../../types'; +import { EMPTY_AUCTION_PARAMS } from '../../constants/trade'; +import { UISerializableOrder } from '../../serializableTypes'; + +export const getLimitPriceFromOracleOffset = ( + order: UISerializableOrder, + oraclePrice: BigNum +): BigNum => { + if ( + (order.price && !order.price.eqZero()) || + !order.oraclePriceOffset || + order.oraclePriceOffset.eqZero() || + !oraclePrice || + oraclePrice?.eqZero() + ) { + return order.price; + } + return oraclePrice.add(order.oraclePriceOffset); +}; + +export function isAuctionEmpty(auctionParams: AuctionParams) { + return ( + auctionParams.auctionStartPrice === + EMPTY_AUCTION_PARAMS.auctionStartPrice && + auctionParams.auctionEndPrice === EMPTY_AUCTION_PARAMS.auctionEndPrice && + auctionParams.auctionDuration === EMPTY_AUCTION_PARAMS.auctionDuration + ); +} diff --git a/common-ts/src/utils/orders/sort.ts b/common-ts/src/utils/orders/sort.ts new file mode 100644 index 00000000..2ca3c224 --- /dev/null +++ b/common-ts/src/utils/orders/sort.ts @@ -0,0 +1,168 @@ +import { + Event, + OrderAction, + OrderActionRecord, + OrderRecord, +} from '@drift-labs/sdk'; +import { + UIMatchedOrderRecordAndAction, + UISerializableOrderActionRecord, +} from '../../serializableTypes'; +import { matchEnum } from '../enum'; + +export type PartialOrderActionRecord = + | PartialUISerializableOrderActionRecord + | PartialOrderActionEventRecord; + +export type PartialUISerializableOrderActionRecord = Pick< + UISerializableOrderActionRecord, + | 'quoteAssetAmountFilled' + | 'baseAssetAmountFilled' + | 'ts' + | 'slot' + | 'action' + | 'fillRecordId' + | 'taker' + | 'takerOrderBaseAssetAmount' + | 'makerOrderBaseAssetAmount' + | 'takerOrderCumulativeBaseAssetAmountFilled' + | 'makerOrderCumulativeBaseAssetAmountFilled' + | 'takerOrderCumulativeQuoteAssetAmountFilled' + | 'makerOrderCumulativeQuoteAssetAmountFilled' + | 'oraclePrice' +>; + +export type PartialOrderActionEventRecord = Pick< + Event, + | 'quoteAssetAmountFilled' + | 'baseAssetAmountFilled' + | 'ts' + | 'slot' + | 'action' + | 'fillRecordId' + | 'taker' + | 'takerOrderBaseAssetAmount' + | 'makerOrderBaseAssetAmount' + | 'takerOrderCumulativeBaseAssetAmountFilled' + | 'makerOrderCumulativeBaseAssetAmountFilled' + | 'takerOrderCumulativeQuoteAssetAmountFilled' + | 'makerOrderCumulativeQuoteAssetAmountFilled' +>; + +const getChronologicalValueForOrderAction = (action: OrderAction) => { + return matchEnum(action, OrderAction.PLACE) + ? 0 + : matchEnum(action, OrderAction.FILL) + ? 1 + : 2; +}; + +/** + * Returns 1 if the first Order is chronologically later than the second Order, -1 if before, 0 if equal + * @param orderA + * @param orderB + * @returns + */ +export const getSortScoreForOrderRecords = ( + orderA: { slot: number }, + orderB: { slot: number } +) => { + if (orderA.slot !== orderB.slot) { + return orderA.slot > orderB.slot ? 1 : -1; + } + + return 0; +}; + +/** + * Returns 1 if the first Order is chronologically later than the second Order, -1 if before, 0 if equal + * @param orderA + * @param orderB + * @returns + */ +export const getSortScoreForOrderActionRecords = ( + orderA: PartialOrderActionRecord, + orderB: PartialOrderActionRecord +) => { + if (orderA.slot !== orderB.slot) { + return orderA.slot > orderB.slot ? 1 : -1; + } + + if (!matchEnum(orderA.action, orderB.action)) { + // @ts-ignore + const orderAActionVal = getChronologicalValueForOrderAction(orderA.action); + // @ts-ignore + const orderBActionVal = getChronologicalValueForOrderAction(orderB.action); + + return orderAActionVal > orderBActionVal ? 1 : -1; + } + // @ts-ignore + if (orderA.fillRecordId && orderB.fillRecordId) { + if (!orderA.fillRecordId.eq(orderB.fillRecordId)) { + // @ts-ignore + return orderA.fillRecordId.gt(orderB.fillRecordId) ? 1 : -1; + } + } + + return 0; +}; + +export const sortUIMatchedOrderRecordAndAction = ( + records: UIMatchedOrderRecordAndAction[], + direction: 'asc' | 'desc' = 'desc' +) => { + const ascSortedRecords = records.sort((a, b) => + getSortScoreForOrderActionRecords(a.actionRecord, b.actionRecord) + ); + + return direction === 'desc' ? ascSortedRecords.reverse() : ascSortedRecords; +}; + +export const sortUIOrderActionRecords = ( + records: PartialUISerializableOrderActionRecord[], + direction: 'asc' | 'desc' = 'desc' +) => { + const ascSortedRecords = records.sort(getSortScoreForOrderActionRecords); + + return direction === 'desc' ? ascSortedRecords.reverse() : ascSortedRecords; +}; + +export const sortUIOrderRecords = ( + records: T[], + direction: 'asc' | 'desc' = 'desc' +) => { + const ascSortedRecords = records.sort(getSortScoreForOrderRecords); + + return direction === 'desc' ? ascSortedRecords.reverse() : ascSortedRecords; +}; + +export const sortOrderRecords = ( + records: Event[], + direction: 'asc' | 'desc' = 'desc' +) => { + const ascSortedRecords = records.sort(getSortScoreForOrderRecords); + + return direction === 'desc' ? ascSortedRecords.reverse() : ascSortedRecords; +}; + +export const getLatestOfTwoUIOrderRecords = ( + orderA: T, + orderB: T +) => { + return getSortScoreForOrderRecords(orderA, orderB) === 1 ? orderA : orderB; +}; + +export const getLatestOfTwoOrderRecords = ( + orderA: T, + orderB: T +) => { + return getSortScoreForOrderRecords(orderA, orderB) === 1 ? orderA : orderB; +}; + +export const getUIOrderRecordsLaterThanTarget = ( + target: T, + records: T[] +) => + records.filter( + (record) => getLatestOfTwoUIOrderRecords(record, target) === record + ); diff --git a/common-ts/src/utils/positions/index.ts b/common-ts/src/utils/positions/index.ts new file mode 100644 index 00000000..aef304ec --- /dev/null +++ b/common-ts/src/utils/positions/index.ts @@ -0,0 +1,2 @@ +export * from './open'; +export * from './user'; diff --git a/common-ts/src/common-ui-utils/user.ts b/common-ts/src/utils/positions/open.ts similarity index 56% rename from common-ts/src/common-ui-utils/user.ts rename to common-ts/src/utils/positions/open.ts index b7e79d9d..831b3532 100644 --- a/common-ts/src/common-ui-utils/user.ts +++ b/common-ts/src/utils/positions/open.ts @@ -10,7 +10,6 @@ import { PerpMarketConfig, PerpPosition, PositionDirection, - PublicKey, QUOTE_PRECISION_EXP, QUOTE_SPOT_MARKET_INDEX, User, @@ -20,14 +19,13 @@ import { calculateEntryPrice, calculateFeesAndFundingPnl, calculatePositionPNL, - getUserAccountPublicKeySync, calculateUnsettledFundingPnl, isOracleValid, AMM_RESERVE_PRECISION, } from '@drift-labs/sdk'; -import { OpenPosition, UIMarket } from '../types'; -import { TRADING_UTILS } from './trading'; -import { ENUM_UTILS } from '../utils'; +import { OpenPosition, UIMarket } from '../../types'; +import { calculatePotentialProfit } from '../trading/pnl'; +import { ENUM_UTILS } from '../enum'; const getOpenPositionData = ( driftClient: DriftClient, @@ -101,7 +99,7 @@ const getOpenPositionData = ( oraclePrice = markPrice; } - const pnlVsMark = TRADING_UTILS.calculatePotentialProfit({ + const pnlVsMark = calculatePotentialProfit({ currentPositionSize: BigNum.from( position.baseAssetAmount.abs(), BASE_PRECISION_EXP @@ -121,7 +119,7 @@ const getOpenPositionData = ( takerFeeBps: 0, }).estimatedProfit.shiftTo(QUOTE_PRECISION_EXP).val; - const pnlVsOracle = TRADING_UTILS.calculatePotentialProfit({ + const pnlVsOracle = calculatePotentialProfit({ currentPositionSize: BigNum.from( position.baseAssetAmount.abs(), BASE_PRECISION_EXP @@ -205,115 +203,4 @@ const getOpenPositionData = ( return newResult; }; -const checkIfUserAccountExists = async ( - driftClient: DriftClient, - config: - | { - type: 'userPubKey'; - userPubKey: PublicKey; - } - | { - type: 'subAccountId'; - subAccountId: number; - authority: PublicKey; - } -) => { - let userPubKey: PublicKey; - - if (config.type === 'userPubKey') { - userPubKey = config.userPubKey; - } else { - userPubKey = getUserAccountPublicKeySync( - driftClient.program.programId, - config.authority, - config.subAccountId - ); - } - - const accountInfo = await driftClient.connection.getAccountInfo(userPubKey); - - return accountInfo !== null; -}; - -/** - * A user's max leverage for a market is stored on-chain in the `PerpPosition` struct of the `UserAccount`. - * There are a few scenarios for how a market's max leverage is defined: - * - * 1. When the user does not have a position ("empty" or not) in the market in their `UserAccount` data, - * and creates an order for the market, an "empty" `PerpPosition` will be upsert to the `UserAccount` data, - * and will contain the max margin ratio set by the user. Note that the `UserAccount` data can store up - * to 8 `PerpPosition` structs, and most of the time the majority of the `PerpPosition` structs will be - * "empty" if the user does not have the max 8 perp positions open. The max leverage is then derived from - * the max margin ratio set in the `PerpPosition` struct. - * - * 2. If the user has a position ("empty" or not), but no open orders and is provided with a saved max leverage, - * the saved max leverage is used. - * - * 3. When the user does not have a position ("empty" or not), it is expected of the UI to store and persist - * the max leverage in the UI client. - * - * 4. In cases where the user has a position before the market max leverage feature was shipped, the - * position is not expected to have a max margin ratio set, and the UI should display the regular max - * leverage for the market, unless the user is already in High Leverage Mode, in which case the UI should - * display the high leverage max leverage for the market (if any). - */ -const getUserMaxLeverageForMarket = ( - user: User | undefined, - marketIndex: number, - marketLeverageDetails: { - regularMaxLeverage: number; - highLeverageMaxLeverage: number; - hasHighLeverage: boolean; - }, - uiSavedMaxLeverage?: number -) => { - // if no saved max leverage is provided, return the regular max leverage for the market - const DEFAULT_MAX_LEVERAGE = - uiSavedMaxLeverage ?? marketLeverageDetails.regularMaxLeverage; - - if (!user) { - return DEFAULT_MAX_LEVERAGE; - } - - const openOrClosedPosition = user.getPerpPosition(marketIndex); // this position does not have to be open, it can be a closed position (a.k.a "empty") but has max margin ratio set. - - if (!openOrClosedPosition) { - return DEFAULT_MAX_LEVERAGE; - } - - const positionHasMaxMarginRatioSet = !!openOrClosedPosition.maxMarginRatio; - const isPositionOpen = !openOrClosedPosition.baseAssetAmount.eq(ZERO); - const hasNoOpenOrders = openOrClosedPosition.openOrders === 0; - - if (positionHasMaxMarginRatioSet) { - // Special case: open position with no orders - use UI saved value if available - if (isPositionOpen && hasNoOpenOrders && uiSavedMaxLeverage) { - return uiSavedMaxLeverage; - } - - return parseFloat( - ((1 / openOrClosedPosition.maxMarginRatio) * 10000).toFixed(2) - ); - } - - if (isPositionOpen) { - // user has an existing position from before PML ship (this means no max margin ratio set onchain yet) - // display max leverage for the leverage mode their account is in - const isUserInHighLeverageMode = user.isHighLeverageMode('Initial'); - const grandfatheredMaxLev = isUserInHighLeverageMode - ? marketLeverageDetails.hasHighLeverage - ? marketLeverageDetails.highLeverageMaxLeverage - : marketLeverageDetails.regularMaxLeverage - : marketLeverageDetails.regularMaxLeverage; - return grandfatheredMaxLev; - } - - // user has closed position with no margin ratio set, return default value - return DEFAULT_MAX_LEVERAGE; -}; - -export const USER_UTILS = { - getOpenPositionData, - checkIfUserAccountExists, - getUserMaxLeverageForMarket, -}; +export { getOpenPositionData }; diff --git a/common-ts/src/utils/positions/user.ts b/common-ts/src/utils/positions/user.ts new file mode 100644 index 00000000..1235167d --- /dev/null +++ b/common-ts/src/utils/positions/user.ts @@ -0,0 +1,116 @@ +import { + DriftClient, + PublicKey, + User, + ZERO, + getUserAccountPublicKeySync, +} from '@drift-labs/sdk'; + +const checkIfUserAccountExists = async ( + driftClient: DriftClient, + config: + | { + type: 'userPubKey'; + userPubKey: PublicKey; + } + | { + type: 'subAccountId'; + subAccountId: number; + authority: PublicKey; + } +) => { + let userPubKey: PublicKey; + + if (config.type === 'userPubKey') { + userPubKey = config.userPubKey; + } else { + userPubKey = getUserAccountPublicKeySync( + driftClient.program.programId, + config.authority, + config.subAccountId + ); + } + + const accountInfo = await driftClient.connection.getAccountInfo(userPubKey); + + return accountInfo !== null; +}; + +/** + * A user's max leverage for a market is stored on-chain in the `PerpPosition` struct of the `UserAccount`. + * There are a few scenarios for how a market's max leverage is defined: + * + * 1. When the user does not have a position ("empty" or not) in the market in their `UserAccount` data, + * and creates an order for the market, an "empty" `PerpPosition` will be upsert to the `UserAccount` data, + * and will contain the max margin ratio set by the user. Note that the `UserAccount` data can store up + * to 8 `PerpPosition` structs, and most of the time the majority of the `PerpPosition` structs will be + * "empty" if the user does not have the max 8 perp positions open. The max leverage is then derived from + * the max margin ratio set in the `PerpPosition` struct. + * + * 2. If the user has a position ("empty" or not), but no open orders and is provided with a saved max leverage, + * the saved max leverage is used. + * + * 3. When the user does not have a position ("empty" or not), it is expected of the UI to store and persist + * the max leverage in the UI client. + * + * 4. In cases where the user has a position before the market max leverage feature was shipped, the + * position is not expected to have a max margin ratio set, and the UI should display the regular max + * leverage for the market, unless the user is already in High Leverage Mode, in which case the UI should + * display the high leverage max leverage for the market (if any). + */ +const getUserMaxLeverageForMarket = ( + user: User | undefined, + marketIndex: number, + marketLeverageDetails: { + regularMaxLeverage: number; + highLeverageMaxLeverage: number; + hasHighLeverage: boolean; + }, + uiSavedMaxLeverage?: number +) => { + // if no saved max leverage is provided, return the regular max leverage for the market + const DEFAULT_MAX_LEVERAGE = + uiSavedMaxLeverage ?? marketLeverageDetails.regularMaxLeverage; + + if (!user) { + return DEFAULT_MAX_LEVERAGE; + } + + const openOrClosedPosition = user.getPerpPosition(marketIndex); // this position does not have to be open, it can be a closed position (a.k.a "empty") but has max margin ratio set. + + if (!openOrClosedPosition) { + return DEFAULT_MAX_LEVERAGE; + } + + const positionHasMaxMarginRatioSet = !!openOrClosedPosition.maxMarginRatio; + const isPositionOpen = !openOrClosedPosition.baseAssetAmount.eq(ZERO); + const hasNoOpenOrders = openOrClosedPosition.openOrders === 0; + + if (positionHasMaxMarginRatioSet) { + // Special case: open position with no orders - use UI saved value if available + if (isPositionOpen && hasNoOpenOrders && uiSavedMaxLeverage) { + return uiSavedMaxLeverage; + } + + return parseFloat( + ((1 / openOrClosedPosition.maxMarginRatio) * 10000).toFixed(2) + ); + } + + if (isPositionOpen) { + // user has an existing position from before PML ship (this means no max margin ratio set onchain yet) + // display max leverage for the leverage mode their account is in + const isUserInHighLeverageMode = user.isHighLeverageMode('Initial'); + const grandfatheredMaxLev = isUserInHighLeverageMode + ? marketLeverageDetails.hasHighLeverage + ? marketLeverageDetails.highLeverageMaxLeverage + : marketLeverageDetails.regularMaxLeverage + : marketLeverageDetails.regularMaxLeverage; + return grandfatheredMaxLev; + } + + // user has closed position with no margin ratio set, return default value + return DEFAULT_MAX_LEVERAGE; +}; + +export { checkIfUserAccountExists, getUserMaxLeverageForMarket }; diff --git a/common-ts/src/common-ui-utils/settings/settings.ts b/common-ts/src/utils/settings/settings.ts similarity index 100% rename from common-ts/src/common-ui-utils/settings/settings.ts rename to common-ts/src/utils/settings/settings.ts diff --git a/common-ts/src/utils/strings.ts b/common-ts/src/utils/strings/convert.ts similarity index 51% rename from common-ts/src/utils/strings.ts rename to common-ts/src/utils/strings/convert.ts index 8fea3efe..f76efec3 100644 --- a/common-ts/src/utils/strings.ts +++ b/common-ts/src/utils/strings/convert.ts @@ -1,68 +1,5 @@ import { BN, BigNum } from '@drift-labs/sdk'; -export const isValidBase58 = (str: string) => - /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(str); - -export function abbreviateAccountName( - name: string, - size = 8, - opts?: { - ellipsisMiddle?: boolean; - } -) { - if (name.length <= size) return name; - - if (opts?.ellipsisMiddle) { - const length = name.length; - const sizeMid = Math.floor(size / 2); - - return name.slice(0, sizeMid) + '...' + name.slice(length - sizeMid); - } - - return name.slice(0, size) + '...'; -} - -export function splitByCapitalLetters(word: string) { - return word.replace(/([A-Z])/g, ' $1').trim(); -} - -export function lowerCaseNonFirstWords(sentence: string): string { - const words = sentence.split(' '); - for (let i = 1; i < words.length; i++) { - words[i] = words[i].toLowerCase(); - } - return words.join(' '); -} - -export const disallowNegativeStringInput = (str: string): string => { - if (str && str.charAt(0) === '-') { - return '0'; - } - return str; -}; - -/** - * LastOrder status types from https://github.com/drift-labs/infrastructure-v3/blob/8ab1888eaaaed96228406b562d4a399729d042d7/packages/common/src/types/index.ts#L221 - */ -const LAST_ORDER_STATUS_LABELS = { - open: 'Open', - filled: 'Filled', - partial_fill: 'Partially Filled', - cancelled: 'Canceled', - partial_fill_cancelled: 'Partially Filled & Canceled', - expired: 'Expired', - trigger: 'Triggered', -} as const; -export type LastOrderStatus = keyof typeof LAST_ORDER_STATUS_LABELS; -export type LastOrderStatusLabel = - (typeof LAST_ORDER_STATUS_LABELS)[LastOrderStatus]; - -export function lastOrderStatusToNormalEng( - status: string -): LastOrderStatusLabel | string { - return LAST_ORDER_STATUS_LABELS[status as LastOrderStatus] ?? status; -} - /** * Recursively converts various types into printable strings. */ diff --git a/common-ts/src/utils/strings/format.ts b/common-ts/src/utils/strings/format.ts new file mode 100644 index 00000000..c11ab40f --- /dev/null +++ b/common-ts/src/utils/strings/format.ts @@ -0,0 +1,68 @@ +import { PublicKey } from '@drift-labs/sdk'; +import { getCachedUiString } from '../core/cache'; + +export const abbreviateAddress = (address: string | PublicKey, length = 4) => { + if (!address) return ''; + const authString = address.toString(); + return getCachedUiString('abbreviate', authString, length); +}; + +/** + * Trim trailing zeros from a numerical string + * @param str - numerical string to format + * @param zerosToShow - max number of zeros to show after the decimal. Similar to number.toFixed() but won't trim non-zero values. Optional, default value is 1 + */ +export const trimTrailingZeros = (str: string, zerosToShow = 1) => { + // Ignore strings with no decimal point + if (!str.includes('.')) return str; + + const sides = str.split('.'); + + sides[1] = sides[1].replace(/0+$/, ''); + + if (sides[1].length < zerosToShow) { + const zerosToAdd = zerosToShow - sides[1].length; + sides[1] = `${sides[1]}${Array(zerosToAdd).fill('0').join('')}`; + } + + if (sides[1].length === 0) { + return sides[0]; + } else { + return sides.join('.'); + } +}; + +export const toSnakeCase = (str: string): string => + str.replace(/[^\w]/g, '_').toLowerCase(); + +export const toCamelCase = (str: string): string => { + const words = str.split(/[_\-\s]+/); // split on underscores, hyphens, and spaces + const firstWord = words[0].toLowerCase(); + const restWords = words + .slice(1) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()); + return [firstWord, ...restWords].join(''); +}; + +export const normalizeBaseAssetSymbol = (symbol: string) => { + return symbol.replace(/^1M/, ''); +}; + +export function abbreviateAccountName( + name: string, + size = 8, + opts?: { + ellipsisMiddle?: boolean; + } +) { + if (name.length <= size) return name; + + if (opts?.ellipsisMiddle) { + const length = name.length; + const sizeMid = Math.floor(size / 2); + + return name.slice(0, sizeMid) + '...' + name.slice(length - sizeMid); + } + + return name.slice(0, size) + '...'; +} diff --git a/common-ts/src/utils/strings/index.ts b/common-ts/src/utils/strings/index.ts new file mode 100644 index 00000000..505ec7cb --- /dev/null +++ b/common-ts/src/utils/strings/index.ts @@ -0,0 +1,4 @@ +export * from './convert'; +export * from './format'; +export * from './parse'; +export * from './status'; diff --git a/common-ts/src/utils/strings/parse.ts b/common-ts/src/utils/strings/parse.ts new file mode 100644 index 00000000..95a4a617 --- /dev/null +++ b/common-ts/src/utils/strings/parse.ts @@ -0,0 +1,21 @@ +export const isValidBase58 = (str: string) => + /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(str); + +export function splitByCapitalLetters(word: string) { + return word.replace(/([A-Z])/g, ' $1').trim(); +} + +export function lowerCaseNonFirstWords(sentence: string): string { + const words = sentence.split(' '); + for (let i = 1; i < words.length; i++) { + words[i] = words[i].toLowerCase(); + } + return words.join(' '); +} + +export const disallowNegativeStringInput = (str: string): string => { + if (str && str.charAt(0) === '-') { + return '0'; + } + return str; +}; diff --git a/common-ts/src/utils/strings/status.ts b/common-ts/src/utils/strings/status.ts new file mode 100644 index 00000000..75369519 --- /dev/null +++ b/common-ts/src/utils/strings/status.ts @@ -0,0 +1,21 @@ +/** + * LastOrder status types from https://github.com/drift-labs/infrastructure-v3/blob/8ab1888eaaaed96228406b562d4a399729d042d7/packages/common/src/types/index.ts#L221 + */ +export const LAST_ORDER_STATUS_LABELS = { + open: 'Open', + filled: 'Filled', + partial_fill: 'Partially Filled', + cancelled: 'Canceled', + partial_fill_cancelled: 'Partially Filled & Canceled', + expired: 'Expired', + trigger: 'Triggered', +} as const; +export type LastOrderStatus = keyof typeof LAST_ORDER_STATUS_LABELS; +export type LastOrderStatusLabel = + (typeof LAST_ORDER_STATUS_LABELS)[LastOrderStatus]; + +export function lastOrderStatusToNormalEng( + status: string +): LastOrderStatusLabel | string { + return LAST_ORDER_STATUS_LABELS[status as LastOrderStatus] ?? status; +} diff --git a/common-ts/src/utils/token.ts b/common-ts/src/utils/token.ts deleted file mode 100644 index 4c421ce8..00000000 --- a/common-ts/src/utils/token.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - SpotMarketAccount, - WRAPPED_SOL_MINT, - getTokenProgramForSpotMarket, -} from '@drift-labs/sdk'; -import { - createAssociatedTokenAccountInstruction, - getAssociatedTokenAddress, -} from '@solana/spl-token'; -import { Connection, PublicKey, TransactionInstruction } from '@solana/web3.js'; - -export { - TOKEN_PROGRAM_ID, - createTransferCheckedInstruction, -} from '@solana/spl-token'; - -export const getTokenAddress = ( - mintAddress: string, - userPubKey: string -): Promise => { - return getAssociatedTokenAddress( - new PublicKey(mintAddress), - new PublicKey(userPubKey), - true - ); -}; - -/** - * Get the associated token address for the given spot market and authority. If the mint is SOL, return the authority public key. - * This should be used for spot token movement in and out of the user's wallet. - * Automatically resolves the correct token program (TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID) from the spot market account. - * @param spotMarketAccount - The spot market account - * @param authority - The authority's public key - * @returns The associated token address - */ -export const getTokenAddressForDepositAndWithdraw = async ( - spotMarketAccount: SpotMarketAccount, - authority: PublicKey -): Promise => { - const isSol = spotMarketAccount.mint.equals(WRAPPED_SOL_MINT); - - if (isSol) return authority; - - return getAssociatedTokenAddress( - spotMarketAccount.mint, - authority, - true, - getTokenProgramForSpotMarket(spotMarketAccount) - ); -}; - -export const getTokenAccount = async ( - connection: Connection, - mintAddress: string, - userPubKey: string -): Promise<{ - pubkey: PublicKey; - account: import('@solana/web3.js').AccountInfo< - import('@solana/web3.js').ParsedAccountData - >; -}> => { - const tokenAccounts = await connection.getParsedTokenAccountsByOwner( - new PublicKey(userPubKey), - { mint: new PublicKey(mintAddress) } - ); - - const associatedAddress = await getAssociatedTokenAddress( - new PublicKey(mintAddress), - new PublicKey(userPubKey), - true - ); - - const targetAccount = - tokenAccounts.value.filter((account) => - account.pubkey.equals(associatedAddress) - )[0] || tokenAccounts.value[0]; - - return targetAccount; -}; - -export const createTokenAccountIx = async ( - owner: PublicKey, - mintAddress: PublicKey, - payer?: PublicKey -): Promise => { - if (!payer) { - payer = owner; - } - - const associatedAddress = await getAssociatedTokenAddress( - mintAddress, - owner, - true - ); - - const createAtaIx = await createAssociatedTokenAccountInstruction( - payer, - associatedAddress, - owner, - mintAddress - ); - - return createAtaIx; -}; diff --git a/common-ts/src/utils/token/account.ts b/common-ts/src/utils/token/account.ts new file mode 100644 index 00000000..a0ac0d36 --- /dev/null +++ b/common-ts/src/utils/token/account.ts @@ -0,0 +1,87 @@ +import { getAssociatedTokenAddress } from '@solana/spl-token'; +import { + AccountInfo, + Connection, + ParsedAccountData, + PublicKey, +} from '@solana/web3.js'; + +export const getTokenAccount = async ( + connection: Connection, + mintAddress: string, + userPubKey: string +): Promise<{ + pubkey: PublicKey; + account: AccountInfo; +}> => { + const tokenAccounts = await connection.getParsedTokenAccountsByOwner( + new PublicKey(userPubKey), + { mint: new PublicKey(mintAddress) } + ); + + const associatedAddress = await getAssociatedTokenAddress( + new PublicKey(mintAddress), + new PublicKey(userPubKey), + true + ); + + const targetAccount = + tokenAccounts.value.filter((account) => + account.pubkey.equals(associatedAddress) + )[0] || tokenAccounts.value[0]; + + return targetAccount; +}; + +export const getBalanceFromTokenAccountResult = (account: { + pubkey: PublicKey; + account: AccountInfo; +}) => { + return account?.account.data?.parsed?.info?.tokenAmount?.uiAmount; +}; + +export const getTokenAccountWithWarning = async ( + connection: Connection, + mintAddress: PublicKey, + userPubKey: PublicKey +): Promise<{ + tokenAccount: { + pubkey: PublicKey; + account: AccountInfo; + }; + tokenAccountWarning: boolean; +}> => { + const tokenAccounts = await connection.getParsedTokenAccountsByOwner( + userPubKey, + { mint: mintAddress } + ); + + const associatedAddress = await getAssociatedTokenAddress( + mintAddress, + userPubKey, + true + ); + + const targetAccount = + tokenAccounts.value.filter((account) => + account.pubkey.equals(associatedAddress) + )[0] || tokenAccounts.value[0]; + + const anotherBalanceExists = tokenAccounts.value.find((account) => { + return ( + !!getBalanceFromTokenAccountResult(account) && + !account.pubkey.equals(targetAccount.pubkey) + ); + }); + + let tokenAccountWarning = false; + + if (anotherBalanceExists) { + tokenAccountWarning = true; + } + + return { + tokenAccount: targetAccount, + tokenAccountWarning, + }; +}; diff --git a/common-ts/src/utils/token/address.ts b/common-ts/src/utils/token/address.ts new file mode 100644 index 00000000..84df7d95 --- /dev/null +++ b/common-ts/src/utils/token/address.ts @@ -0,0 +1,49 @@ +import { + SpotMarketAccount, + WRAPPED_SOL_MINT, + getTokenProgramForSpotMarket, +} from '@drift-labs/sdk'; +import { getAssociatedTokenAddress } from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; + +export const getTokenAddress = ( + mintAddress: string, + userPubKey: string +): Promise => { + return getAssociatedTokenAddress( + new PublicKey(mintAddress), + new PublicKey(userPubKey), + true + ); +}; + +export const getTokenAddressFromPublicKeys = ( + mintAddress: PublicKey, + userPubKey: PublicKey +): Promise => { + return getAssociatedTokenAddress(mintAddress, userPubKey, true); +}; + +/** + * Get the associated token address for the given spot market and authority. If the mint is SOL, return the authority public key. + * This should be used for spot token movement in and out of the user's wallet. + * Automatically resolves the correct token program (TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID) from the spot market account. + * @param spotMarketAccount - The spot market account + * @param authority - The authority's public key + * @returns The associated token address + */ +export const getTokenAddressForDepositAndWithdraw = async ( + spotMarketAccount: SpotMarketAccount, + authority: PublicKey +): Promise => { + const isSol = spotMarketAccount.mint.equals(WRAPPED_SOL_MINT); + + if (isSol) return authority; + + return getAssociatedTokenAddress( + spotMarketAccount.mint, + authority, + true, + getTokenProgramForSpotMarket(spotMarketAccount) + ); +}; diff --git a/common-ts/src/utils/token/index.ts b/common-ts/src/utils/token/index.ts new file mode 100644 index 00000000..795dbf19 --- /dev/null +++ b/common-ts/src/utils/token/index.ts @@ -0,0 +1,3 @@ +export * from './address'; +export * from './account'; +export * from './instructions'; diff --git a/common-ts/src/utils/token/instructions.ts b/common-ts/src/utils/token/instructions.ts new file mode 100644 index 00000000..3acdc606 --- /dev/null +++ b/common-ts/src/utils/token/instructions.ts @@ -0,0 +1,35 @@ +import { + createAssociatedTokenAccountInstruction, + getAssociatedTokenAddress, +} from '@solana/spl-token'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; + +export { + TOKEN_PROGRAM_ID, + createTransferCheckedInstruction, +} from '@solana/spl-token'; + +export const createTokenAccountIx = async ( + owner: PublicKey, + mintAddress: PublicKey, + payer?: PublicKey +): Promise => { + if (!payer) { + payer = owner; + } + + const associatedAddress = await getAssociatedTokenAddress( + mintAddress, + owner, + true + ); + + const createAtaIx = await createAssociatedTokenAccountInstruction( + payer, + associatedAddress, + owner, + mintAddress + ); + + return createAtaIx; +}; diff --git a/common-ts/src/utils/trading/auction.ts b/common-ts/src/utils/trading/auction.ts new file mode 100644 index 00000000..55956102 --- /dev/null +++ b/common-ts/src/utils/trading/auction.ts @@ -0,0 +1,402 @@ +import { + BN, + BigNum, + MarketType, + OptionalOrderParams, + OrderType, + PRICE_PRECISION, + PositionDirection, + ZERO, + deriveOracleAuctionParams, + getMarketOrderParams, + isVariant, +} from '@drift-labs/sdk'; +import { AuctionParams, TradeOffsetPrice } from '../../types'; +import { EMPTY_AUCTION_PARAMS } from '../../constants/trade'; +import { getMarketOrderLimitPrice } from './price'; + +const getMarketAuctionParams = ({ + direction, + startPriceFromSettings, + endPriceFromSettings, + limitPrice, + duration, + auctionStartPriceOffset, + auctionEndPriceOffset, + additionalEndPriceBuffer, + forceUpToSlippage, + bestBidPrice, + bestAskPrice, + ensureCrossingEndPrice, +}: { + direction: PositionDirection; + startPriceFromSettings: BN; + endPriceFromSettings: BN; + /** + * Limit price is the oracle limit price - market orders use the oracle order type under the hood on Drift UI + * So oracle limit price is the oracle price + oracle offset + */ + limitPrice: BN; + duration: number; + auctionStartPriceOffset: number; + auctionEndPriceOffset: number; + additionalEndPriceBuffer?: BN; + forceUpToSlippage?: boolean; + bestBidPrice?: BN; + bestAskPrice?: BN; + ensureCrossingEndPrice?: boolean; +}): AuctionParams => { + let auctionStartPrice: BN; + let auctionEndPrice: BN; + let constrainedBySlippage: boolean; + + const auctionEndPriceBuffer = BigNum.from(PRICE_PRECISION).scale( + Math.abs(auctionEndPriceOffset * 100), + 10000 + ).val; + + const auctionStartPriceBuffer = BigNum.from(startPriceFromSettings).scale( + Math.abs(auctionStartPriceOffset * 100), + 10000 + ).val; + + if (isVariant(direction, 'long')) { + auctionStartPrice = startPriceFromSettings.sub(auctionStartPriceBuffer); + + const worstPriceToUse = BN.max( + endPriceFromSettings, + startPriceFromSettings + ); // Handles edge cases like if the worst price on the book was better than the oracle price, and the settings are asking to be relative to the oracle price + + auctionEndPrice = PRICE_PRECISION.add(auctionEndPriceBuffer) + .mul(worstPriceToUse) + .div(PRICE_PRECISION); + + constrainedBySlippage = limitPrice.lt(auctionEndPrice); + + // if forceUpToSlippage is passed, use max slippage price as end price + if (forceUpToSlippage) { + auctionEndPrice = limitPrice; + constrainedBySlippage = false; + } else { + // use BEST (limit price, auction end price) as end price + auctionEndPrice = BN.min(limitPrice, auctionEndPrice); + } + + // apply additional buffer if provided + if (additionalEndPriceBuffer) { + auctionEndPrice = auctionEndPrice.add(additionalEndPriceBuffer); + constrainedBySlippage = limitPrice.lt(auctionEndPrice); + } + + // if ensureCrossingEndPrice is passed, ensure auction end price crosses bestAskPrice + if (ensureCrossingEndPrice && bestAskPrice) { + auctionEndPrice = BN.max( + auctionEndPrice, + bestAskPrice.add(auctionEndPriceBuffer) + ); + } + + auctionStartPrice = BN.min(auctionStartPrice, auctionEndPrice); + } else { + auctionStartPrice = startPriceFromSettings.add(auctionStartPriceBuffer); + + const worstPriceToUse = BN.min( + endPriceFromSettings, + startPriceFromSettings + ); // Handles edge cases like if the worst price on the book was better than the oracle price, and the settings are asking to be relative to the oracle price + + auctionEndPrice = PRICE_PRECISION.sub(auctionEndPriceBuffer) + .mul(worstPriceToUse) + .div(PRICE_PRECISION); + + constrainedBySlippage = limitPrice.gt(auctionEndPrice); + + // if forceUpToSlippage is passed, use max slippage price as end price + if (forceUpToSlippage) { + auctionEndPrice = limitPrice; + constrainedBySlippage = false; + } else { + // use BEST (limit price, auction end price) as end price + auctionEndPrice = BN.max(limitPrice, auctionEndPrice); + } + + // apply additional buffer if provided + if (additionalEndPriceBuffer) { + auctionEndPrice = auctionEndPrice.sub(additionalEndPriceBuffer); + constrainedBySlippage = limitPrice.gt(auctionEndPrice); + } + + // if ensureCrossingEndPrice is passed, ensure auction end price crosses bestBidPrice + if (ensureCrossingEndPrice && bestBidPrice) { + auctionEndPrice = BN.min( + auctionEndPrice, + bestBidPrice.sub(auctionEndPriceBuffer) + ); + } + + auctionStartPrice = BN.max(auctionStartPrice, auctionEndPrice); + } + + return { + auctionStartPrice, + auctionEndPrice, + auctionDuration: duration, + constrainedBySlippage, + }; +}; + +/** + * Helper function which derived market order params from the CORE data that is used to create them. + * @param param0 + * @returns + */ +const deriveMarketOrderParams = ({ + marketType, + marketIndex, + direction, + maxLeverageSelected, + maxLeverageOrderSize, + baseAmount, + reduceOnly, + allowInfSlippage, + oraclePrice, + bestPrice, + entryPrice, + worstPrice, + markPrice, + auctionDuration, + auctionStartPriceOffset, + auctionEndPriceOffset, + auctionStartPriceOffsetFrom, + auctionEndPriceOffsetFrom, + auctionPriceCaps, + slippageTolerance, + isOracleOrder, + additionalEndPriceBuffer, + forceUpToSlippage, + bestBidPrice, + bestAskPrice, + ensureCrossingEndPrice, +}: { + marketType: MarketType; + marketIndex: number; + direction: PositionDirection; + maxLeverageSelected: boolean; + maxLeverageOrderSize: BN; + baseAmount: BN; + reduceOnly: boolean; + allowInfSlippage: boolean; + oraclePrice: BN; + bestPrice: BN; + entryPrice: BN; + worstPrice: BN; + markPrice: BN; + auctionDuration: number; + auctionStartPriceOffset: number; + auctionEndPriceOffset: number; + auctionPriceCaps?: { + min: BN; + max: BN; + }; + auctionStartPriceOffsetFrom: TradeOffsetPrice; + auctionEndPriceOffsetFrom: TradeOffsetPrice; + slippageTolerance: number; + isOracleOrder?: boolean; + additionalEndPriceBuffer?: BN; + forceUpToSlippage?: boolean; + bestBidPrice?: BN; + bestAskPrice?: BN; + ensureCrossingEndPrice?: boolean; +}): OptionalOrderParams & { constrainedBySlippage?: boolean } => { + const priceObject = getPriceObject({ + oraclePrice, + markPrice, + bestOffer: bestPrice, + entryPrice, + worstPrice, + direction, + }); + + // max slippage price + let limitPrice = getMarketOrderLimitPrice({ + direction, + baselinePrice: priceObject[auctionStartPriceOffsetFrom], + slippageTolerance: allowInfSlippage ? undefined : slippageTolerance, + }); + + if (additionalEndPriceBuffer) { + limitPrice = isVariant(direction, 'long') + ? limitPrice.add(additionalEndPriceBuffer) + : limitPrice.sub(additionalEndPriceBuffer); + } + + const auctionParams = getMarketAuctionParams({ + direction, + startPriceFromSettings: priceObject[auctionStartPriceOffsetFrom], + endPriceFromSettings: priceObject[auctionEndPriceOffsetFrom], + limitPrice, + duration: auctionDuration, + auctionStartPriceOffset: auctionStartPriceOffset, + auctionEndPriceOffset: auctionEndPriceOffset, + additionalEndPriceBuffer, + forceUpToSlippage, + bestBidPrice, + bestAskPrice, + ensureCrossingEndPrice, + }); + + let orderParams = getMarketOrderParams({ + marketType, + marketIndex, + direction, + baseAssetAmount: maxLeverageSelected ? maxLeverageOrderSize : baseAmount, + reduceOnly, + price: allowInfSlippage ? undefined : limitPrice, + ...auctionParams, + }); + + if (isOracleOrder) { + // wont work if oracle is zero + if (!oraclePrice.eq(ZERO)) { + const oracleAuctionParams = deriveOracleAuctionParams({ + direction: direction, + oraclePrice, + auctionStartPrice: auctionParams.auctionStartPrice, + auctionEndPrice: auctionParams.auctionEndPrice, + limitPrice: auctionParams.auctionEndPrice, + auctionPriceCaps: auctionPriceCaps, + }); + + orderParams = { + ...orderParams, + ...oracleAuctionParams, + price: undefined, + orderType: OrderType.ORACLE, + }; + } + } + + return orderParams; +}; + +const getLimitAuctionParams = ({ + direction, + inputPrice, + startPriceFromSettings, + duration, + auctionStartPriceOffset, + oraclePriceBands, +}: { + direction: PositionDirection; + inputPrice: BigNum; + startPriceFromSettings: BN; + duration: number; + auctionStartPriceOffset: number; + oraclePriceBands?: [BN, BN]; +}): AuctionParams => { + let limitAuctionParams = EMPTY_AUCTION_PARAMS; + + const auctionStartPriceBuffer = inputPrice.scale( + Math.abs(auctionStartPriceOffset * 100), + 10000 + ).val; + + if ( + isVariant(direction, 'long') && + startPriceFromSettings && + startPriceFromSettings.lt(inputPrice.val) && + startPriceFromSettings.gt(ZERO) + ) { + limitAuctionParams = { + auctionStartPrice: startPriceFromSettings.sub(auctionStartPriceBuffer), + auctionEndPrice: inputPrice.val, + auctionDuration: duration, + }; + } else if ( + isVariant(direction, 'short') && + startPriceFromSettings && + startPriceFromSettings.gt(ZERO) && + startPriceFromSettings.gt(inputPrice.val) + ) { + limitAuctionParams = { + auctionStartPrice: startPriceFromSettings.add(auctionStartPriceBuffer), + auctionEndPrice: inputPrice.val, + auctionDuration: duration, + }; + } + + if (oraclePriceBands && limitAuctionParams.auctionDuration) { + const [minPrice, maxPrice] = oraclePriceBands; + + // start and end price cant be outside of the oracle price bands + limitAuctionParams.auctionStartPrice = BN.max( + BN.min(limitAuctionParams.auctionStartPrice, maxPrice), + minPrice + ); + + limitAuctionParams.auctionEndPrice = BN.max( + BN.min(limitAuctionParams.auctionEndPrice, maxPrice), + minPrice + ); + } + + return limitAuctionParams; +}; + +const getPriceObject = ({ + oraclePrice, + bestOffer, + entryPrice, + worstPrice, + markPrice, + direction, +}: { + oraclePrice: BN; + bestOffer: BN; + entryPrice: BN; + worstPrice: BN; + markPrice: BN; + direction: PositionDirection; +}) => { + let best: BN; + + const nonZeroOptions = [oraclePrice, bestOffer, markPrice].filter( + (price) => price !== undefined && price?.gt(ZERO) + ); + + if (nonZeroOptions.length === 0) { + // console.error('Unable to create valid auction params'); + return { + oracle: ZERO, + bestOffer: ZERO, + entry: ZERO, + best: ZERO, + worst: ZERO, + mark: ZERO, + }; + } + + if (isVariant(direction, 'long')) { + best = nonZeroOptions.reduce((a, b) => (a.lt(b) ? a : b)); // lowest price + } else { + best = nonZeroOptions.reduce((a, b) => (a.gt(b) ? a : b)); // highest price + } + + // if zero values come through, fallback to nonzero value + return { + oracle: oraclePrice?.gt(ZERO) ? oraclePrice : best, + bestOffer: bestOffer?.gt(ZERO) ? bestOffer : best, + entry: entryPrice, + best, + worst: worstPrice, + mark: markPrice?.gt(ZERO) ? markPrice : best, + }; +}; + +export { + getMarketAuctionParams, + deriveMarketOrderParams, + getLimitAuctionParams, + getPriceObject, +}; diff --git a/common-ts/src/utils/trading/index.ts b/common-ts/src/utils/trading/index.ts new file mode 100644 index 00000000..14551c78 --- /dev/null +++ b/common-ts/src/utils/trading/index.ts @@ -0,0 +1,7 @@ +export * from './auction'; +export * from './leverage'; +export * from './liquidation'; +export * from './lp'; +export * from './pnl'; +export * from './price'; +export * from './size'; diff --git a/common-ts/src/utils/trading/leverage.ts b/common-ts/src/utils/trading/leverage.ts new file mode 100644 index 00000000..008033c2 --- /dev/null +++ b/common-ts/src/utils/trading/leverage.ts @@ -0,0 +1,103 @@ +import { BN, MARGIN_PRECISION, User } from '@drift-labs/sdk'; + +const convertLeverageToMarginRatio = (leverage: number): number | undefined => { + if (!leverage) return undefined; + return Math.round((1 / leverage) * MARGIN_PRECISION.toNumber()); +}; + +const convertMarginRatioToLeverage = ( + marginRatio: number, + decimals?: number +): number | undefined => { + if (!marginRatio) return undefined; + + const leverage = 1 / (marginRatio / MARGIN_PRECISION.toNumber()); + + return decimals + ? parseFloat(leverage.toFixed(decimals)) + : Math.round(leverage); +}; + +/** + * Calculate the margin used for a specific perp position + * Returns the minimum of user's total collateral or the position's weighted value + */ +const getMarginUsedForPosition = ( + user: User, + marketIndex: number, + includeOpenOrders = true +): BN | undefined => { + const perpPosition = user.getPerpPosition(marketIndex); + if (!perpPosition) return undefined; + + const hc = user.getPerpPositionHealth({ + marginCategory: 'Initial', + perpPosition, + includeOpenOrders, + }); + const userCollateral = user.getTotalCollateral(); + return userCollateral.lt(hc.weightedValue) + ? userCollateral + : hc.weightedValue; +}; + +/** + * Validate if a leverage change would exceed the user's free collateral + * Returns true if the change is valid (doesn't exceed free collateral), false otherwise + */ +const validateLeverageChange = ({ + user, + marketIndex, + newLeverage, +}: { + user: User; + marketIndex: number; + newLeverage: number; +}): boolean => { + try { + // Convert leverage to margin ratio + const newMarginRatio = convertLeverageToMarginRatio(newLeverage); + if (!newMarginRatio) return true; + + // Get the perp position from the user + const perpPosition = user.getPerpPosition(marketIndex); + if (!perpPosition) return true; + + // Get current position weighted value + const currentPositionWeightedValue = user.getPerpPositionHealth({ + marginCategory: 'Initial', + perpPosition, + }).weightedValue; + + // Create a modified version of the position with new maxMarginRatio + const modifiedPosition = { + ...perpPosition, + maxMarginRatio: newMarginRatio, + }; + + // Calculate new weighted value with the modified position + const newPositionWeightedValue = user.getPerpPositionHealth({ + marginCategory: 'Initial', + perpPosition: modifiedPosition, + }).weightedValue; + + const perpPositionWeightedValueDelta = newPositionWeightedValue.sub( + currentPositionWeightedValue + ); + + const freeCollateral = user.getFreeCollateral(); + + // Check if weighted value delta exceeds free collateral + return perpPositionWeightedValueDelta.lte(freeCollateral); + } catch (error) { + console.warn('Error validating leverage change:', error); + return true; // Allow change if validation fails + } +}; + +export { + convertLeverageToMarginRatio, + convertMarginRatioToLeverage, + getMarginUsedForPosition, + validateLeverageChange, +}; diff --git a/common-ts/src/utils/trading/liquidation.ts b/common-ts/src/utils/trading/liquidation.ts new file mode 100644 index 00000000..525cf24c --- /dev/null +++ b/common-ts/src/utils/trading/liquidation.ts @@ -0,0 +1,123 @@ +import { BN, BigNum, PRICE_PRECISION_EXP, User } from '@drift-labs/sdk'; +import { UIOrderType } from '../../types'; + +/** + * Calculate the liquidation price of a position after a trade. Requires DriftClient to be subscribed. + * If the order type is limit order, a limit price must be provided. + */ +const calculateLiquidationPriceAfterPerpTrade = ({ + estEntryPrice, + orderType, + perpMarketIndex, + tradeBaseSize, + isLong, + userClient, + oraclePrice, + limitPrice, + offsetCollateral, + precision = 2, + isEnteringHighLeverageMode, + capLiqPrice, + marginType, +}: { + estEntryPrice: BN; + orderType: UIOrderType; + perpMarketIndex: number; + tradeBaseSize: BN; + isLong: boolean; + userClient: User; + oraclePrice: BN; + limitPrice?: BN; + offsetCollateral?: BN; + precision?: number; + isEnteringHighLeverageMode?: boolean; + capLiqPrice?: boolean; + marginType?: 'Cross' | 'Isolated'; +}) => { + const ALLOWED_ORDER_TYPES: UIOrderType[] = [ + 'limit', + 'market', + 'oracle', + 'stopMarket', + 'stopLimit', + 'oracleLimit', + ]; + + if (!ALLOWED_ORDER_TYPES.includes(orderType)) { + console.error( + 'Invalid order type for perp trade liquidation price calculation', + orderType + ); + return 0; + } + + if (orderType === 'limit' && !limitPrice) { + console.error( + 'Limit order must have a limit price for perp trade liquidation price calculation' + ); + return 0; + } + + const signedBaseSize = isLong ? tradeBaseSize : tradeBaseSize.neg(); + const priceToUse = [ + 'limit', + 'stopMarket', + 'stopLimit', + 'oracleLimit', + ].includes(orderType) + ? limitPrice + : estEntryPrice; + + const liqPriceBn = userClient.liquidationPrice( + perpMarketIndex, + signedBaseSize, + priceToUse, + undefined, + undefined, // we can exclude open orders since open orders will be cancelled first (which results in reducing account leverage) before actual liquidation + offsetCollateral, + isEnteringHighLeverageMode, + marginType === 'Isolated' ? 'Isolated' : undefined + ); + + if (liqPriceBn.isNeg()) { + // means no liquidation price + return 0; + } + + // Check if user has a spot position using the same oracle as the perp market + // If so, force capLiqPrice to be false to avoid incorrect price capping + // Technically in this case, liq price could be lower for a short or higher for a long + const perpMarketOracle = + userClient.driftClient.getPerpMarketAccount(perpMarketIndex)?.amm?.oracle; + + const spotMarketWithSameOracle = userClient.driftClient + .getSpotMarketAccounts() + .find((market) => market.oracle.equals(perpMarketOracle)); + + let hasSpotPositionWithSameOracle = false; + if (spotMarketWithSameOracle) { + const spotPosition = userClient.getSpotPosition( + spotMarketWithSameOracle.marketIndex + ); + hasSpotPositionWithSameOracle = !!spotPosition; + } + + const effectiveCapLiqPrice = hasSpotPositionWithSameOracle + ? false + : capLiqPrice; + + const cappedLiqPriceBn = effectiveCapLiqPrice + ? isLong + ? BN.min(liqPriceBn, oraclePrice) + : BN.max(liqPriceBn, oraclePrice) + : liqPriceBn; + + const liqPriceBigNum = BigNum.from(cappedLiqPriceBn, PRICE_PRECISION_EXP); + + const liqPriceNum = + Math.round(liqPriceBigNum.toNum() * 10 ** precision) / 10 ** precision; + + return liqPriceNum; +}; + +export { calculateLiquidationPriceAfterPerpTrade }; diff --git a/common-ts/src/utils/trading/lp.ts b/common-ts/src/utils/trading/lp.ts new file mode 100644 index 00000000..b93585ce --- /dev/null +++ b/common-ts/src/utils/trading/lp.ts @@ -0,0 +1,43 @@ +import { + AMM_RESERVE_PRECISION_EXP, + BN, + BigNum, + DriftClient, + QUOTE_PRECISION_EXP, +} from '@drift-labs/sdk'; + +/* LP Utils */ +const getLpSharesAmountForQuote = ( + driftClient: DriftClient, + marketIndex: number, + quoteAmount: BN +): BigNum => { + const tenMillionBigNum = BigNum.fromPrint('10000000', QUOTE_PRECISION_EXP); + + const pricePerLpShare = BigNum.from( + driftClient.getQuoteValuePerLpShare(marketIndex), + QUOTE_PRECISION_EXP + ); + + return BigNum.from(quoteAmount, QUOTE_PRECISION_EXP) + .scale( + tenMillionBigNum.toNum(), + pricePerLpShare.mul(tenMillionBigNum).toNum() + ) + .shiftTo(AMM_RESERVE_PRECISION_EXP); +}; + +const getQuoteValueForLpShares = ( + driftClient: DriftClient, + marketIndex: number, + sharesAmount: BN +): BigNum => { + const pricePerLpShare = BigNum.from( + driftClient.getQuoteValuePerLpShare(marketIndex), + QUOTE_PRECISION_EXP + ).shiftTo(AMM_RESERVE_PRECISION_EXP); + const lpSharesBigNum = BigNum.from(sharesAmount, AMM_RESERVE_PRECISION_EXP); + return lpSharesBigNum.mul(pricePerLpShare).shiftTo(QUOTE_PRECISION_EXP); +}; + +export { getLpSharesAmountForQuote, getQuoteValueForLpShares }; diff --git a/common-ts/src/utils/trading/pnl.ts b/common-ts/src/utils/trading/pnl.ts new file mode 100644 index 00000000..102f9a14 --- /dev/null +++ b/common-ts/src/utils/trading/pnl.ts @@ -0,0 +1,143 @@ +import { + BN, + BigNum, + PRICE_PRECISION_EXP, + PositionDirection, + QUOTE_PRECISION_EXP, + ZERO, + isVariant, +} from '@drift-labs/sdk'; +import { OpenPosition } from '../../types'; +import { convertMarginRatioToLeverage } from './leverage'; + +const calculatePnlPctFromPosition = ( + pnl: BN, + position: OpenPosition, + marginUsed?: BN +): number => { + if (!position?.quoteEntryAmount || position?.quoteEntryAmount.eq(ZERO)) + return 0; + + let marginUsedNum: number; + + if (marginUsed) { + marginUsedNum = BigNum.from(marginUsed, QUOTE_PRECISION_EXP).toNum(); + } else { + const leverage = convertMarginRatioToLeverage(position.maxMarginRatio) ?? 1; + const quoteEntryAmountNum = BigNum.from( + position.quoteEntryAmount.abs(), + QUOTE_PRECISION_EXP + ).toNum(); + + if (leverage <= 0 || quoteEntryAmountNum <= 0) { + marginUsedNum = 0; + } else { + marginUsedNum = quoteEntryAmountNum / leverage; + } + } + + if (marginUsedNum <= 0) { + return 0; + } + + return ( + BigNum.from(pnl, QUOTE_PRECISION_EXP) + .shift(5) + .div(BigNum.fromPrint(`${marginUsedNum}`, QUOTE_PRECISION_EXP)) + .toNum() * 100 + ); +}; + +export const POTENTIAL_PROFIT_DEFAULT_STATE = { + estimatedProfit: BigNum.zero(PRICE_PRECISION_EXP), + estimatedProfitBeforeFees: BigNum.zero(PRICE_PRECISION_EXP), + estimatedTakerFee: BigNum.zero(PRICE_PRECISION_EXP), + notionalSizeAtEntry: BigNum.zero(PRICE_PRECISION_EXP), + notionalSizeAtExit: BigNum.zero(PRICE_PRECISION_EXP), +}; + +const calculatePotentialProfit = (props: { + currentPositionSize: BigNum; + currentPositionDirection: PositionDirection; + currentPositionEntryPrice: BigNum; + tradeDirection: PositionDirection; + /** + * Amount of position being closed in base asset size + */ + exitBaseSize: BigNum; + /** + * Either the user's limit price (for limit orders) or the estimated exit price (for market orders) + */ + exitPrice: BigNum; + takerFeeBps: number; + slippageTolerance?: number; + isMarketOrder?: boolean; +}): { + estimatedProfit: BigNum; + estimatedProfitBeforeFees: BigNum; + estimatedTakerFee: BigNum; + notionalSizeAtEntry: BigNum; + notionalSizeAtExit: BigNum; +} => { + let estimatedProfit = BigNum.zero(PRICE_PRECISION_EXP); + let estimatedProfitBeforeFees = BigNum.zero(PRICE_PRECISION_EXP); + let estimatedTakerFee = BigNum.zero(PRICE_PRECISION_EXP); + let notionalSizeAtEntry = BigNum.zero(PRICE_PRECISION_EXP); + let notionalSizeAtExit = BigNum.zero(PRICE_PRECISION_EXP); + + const isClosingLong = + isVariant(props.currentPositionDirection, 'long') && + isVariant(props.tradeDirection, 'short'); + const isClosingShort = + isVariant(props.currentPositionDirection, 'short') && + isVariant(props.tradeDirection, 'long'); + + if (!isClosingLong && !isClosingShort) return POTENTIAL_PROFIT_DEFAULT_STATE; + if (!props.exitBaseSize) return POTENTIAL_PROFIT_DEFAULT_STATE; + + if ( + props.exitBaseSize.eqZero() || + props.currentPositionSize.lt(props.exitBaseSize) + ) { + return POTENTIAL_PROFIT_DEFAULT_STATE; + } + + const baseSizeBeingClosed = props.exitBaseSize.lte(props.currentPositionSize) + ? props.exitBaseSize + : props.currentPositionSize; + + // Notional size of amount being closed at entry and exit + notionalSizeAtEntry = baseSizeBeingClosed.mul( + props.currentPositionEntryPrice.shiftTo(baseSizeBeingClosed.precision) + ); + notionalSizeAtExit = baseSizeBeingClosed.mul( + props.exitPrice.shiftTo(baseSizeBeingClosed.precision) + ); + + if (isClosingLong) { + estimatedProfitBeforeFees = notionalSizeAtExit.sub(notionalSizeAtEntry); + } else if (isClosingShort) { + estimatedProfitBeforeFees = notionalSizeAtEntry.sub(notionalSizeAtExit); + } + + // subtract takerFee if applicable + if (props.takerFeeBps > 0) { + const takerFeeDenominator = Math.floor(100 / (props.takerFeeBps * 0.01)); + estimatedTakerFee = notionalSizeAtExit.scale(1, takerFeeDenominator); + estimatedProfit = estimatedProfitBeforeFees.sub( + estimatedTakerFee.shiftTo(estimatedProfitBeforeFees.precision) + ); + } else { + estimatedProfit = estimatedProfitBeforeFees; + } + + return { + estimatedProfit, + estimatedProfitBeforeFees, + estimatedTakerFee, + notionalSizeAtEntry, + notionalSizeAtExit, + }; +}; + +export { calculatePnlPctFromPosition, calculatePotentialProfit }; diff --git a/common-ts/src/utils/trading/price.ts b/common-ts/src/utils/trading/price.ts new file mode 100644 index 00000000..4ef22f74 --- /dev/null +++ b/common-ts/src/utils/trading/price.ts @@ -0,0 +1,54 @@ +import { + BN, + PRICE_PRECISION, + PositionDirection, + ZERO, + isVariant, +} from '@drift-labs/sdk'; +import { UIOrderType } from '../../types'; + +const getMarketOrderLimitPrice = ({ + direction, + baselinePrice, + slippageTolerance, +}: { + direction: PositionDirection; + baselinePrice: BN; + slippageTolerance: number; +}): BN => { + let limitPrice; + + if (!baselinePrice) return ZERO; + + if (slippageTolerance === 0) return baselinePrice; + + // infinite slippage capped at 15% currently + if (slippageTolerance == undefined) slippageTolerance = 15; + + // if manually entered, cap at 99% + if (slippageTolerance > 99) slippageTolerance = 99; + + let limitPricePctDiff; + if (isVariant(direction, 'long')) { + limitPricePctDiff = PRICE_PRECISION.add( + new BN(slippageTolerance * PRICE_PRECISION.toNumber()).div(new BN(100)) + ); + limitPrice = baselinePrice.mul(limitPricePctDiff).div(PRICE_PRECISION); + } else { + limitPricePctDiff = PRICE_PRECISION.sub( + new BN(slippageTolerance * PRICE_PRECISION.toNumber()).div(new BN(100)) + ); + limitPrice = baselinePrice.mul(limitPricePctDiff).div(PRICE_PRECISION); + } + + return limitPrice; +}; + +/** + * Check if the order type is a market order or oracle market order + */ +const checkIsMarketOrderType = (orderType: UIOrderType) => { + return orderType === 'market' || orderType === 'oracle'; +}; + +export { getMarketOrderLimitPrice, checkIsMarketOrderType }; diff --git a/common-ts/src/utils/trading/size.ts b/common-ts/src/utils/trading/size.ts new file mode 100644 index 00000000..ad568d2c --- /dev/null +++ b/common-ts/src/utils/trading/size.ts @@ -0,0 +1,136 @@ +import { + AMM_RESERVE_PRECISION, + BN, + BigNum, + DriftClient, + MAX_LEVERAGE_ORDER_SIZE, + ONE, + PRICE_PRECISION, + PerpMarketAccount, + SpotMarketAccount, + ZERO, +} from '@drift-labs/sdk'; +import { MarketId } from '../../types'; + +const getMarketTickSize = ( + driftClient: DriftClient, + marketId: MarketId +): BN => { + const marketAccount = marketId.isPerp + ? driftClient.getPerpMarketAccount(marketId.marketIndex) + : driftClient.getSpotMarketAccount(marketId.marketIndex); + if (!marketAccount) return ZERO; + + if (marketId.isPerp) { + return (marketAccount as PerpMarketAccount).amm.orderTickSize; + } else { + return (marketAccount as SpotMarketAccount).orderTickSize; + } +}; + +const getMarketTickSizeDecimals = ( + driftClient: DriftClient, + marketId: MarketId +) => { + const tickSize = getMarketTickSize(driftClient, marketId); + + const decimalPlaces = Math.max( + 0, + Math.floor( + Math.log10( + PRICE_PRECISION.div(tickSize.eq(ZERO) ? ONE : tickSize).toNumber() + ) + ) + ); + + return decimalPlaces; +}; + +const getMarketStepSize = (driftClient: DriftClient, marketId: MarketId) => { + const marketAccount = marketId.isPerp + ? driftClient.getPerpMarketAccount(marketId.marketIndex) + : driftClient.getSpotMarketAccount(marketId.marketIndex); + if (!marketAccount) return ZERO; + + if (marketId.isPerp) { + return (marketAccount as PerpMarketAccount).amm.orderStepSize; + } else { + return (marketAccount as SpotMarketAccount).orderStepSize; + } +}; + +const getMarketStepSizeDecimals = ( + driftClient: DriftClient, + marketId: MarketId +) => { + const stepSize = getMarketStepSize(driftClient, marketId); + + const decimalPlaces = Math.max( + 0, + Math.floor( + Math.log10( + AMM_RESERVE_PRECISION.div(stepSize.eq(ZERO) ? ONE : stepSize).toNumber() + ) + ) + ); + + return decimalPlaces; +}; + +/** + * Checks if a given order amount represents an entire position order + * by comparing it with MAX_LEVERAGE_ORDER_SIZE + * @param orderAmount - The BigNum order amount to check + * @returns true if the order is for the entire position, false otherwise + */ +export const isEntirePositionOrder = (orderAmount: BigNum): boolean => { + const maxLeverageSize = new BigNum( + MAX_LEVERAGE_ORDER_SIZE, + orderAmount.precision + ); + + const isMaxLeverage = Math.abs(maxLeverageSize.sub(orderAmount).toNum()) < 1; + + // Some order paths produce a truncated u64::MAX instead of MAX_LEVERAGE_ORDER_SIZE + const ALTERNATIVE_MAX_ORDER_SIZE = '18446744072000000000'; + const alternativeMaxSize = new BigNum( + ALTERNATIVE_MAX_ORDER_SIZE, + orderAmount.precision + ); + const isAlternativeMax = + Math.abs(alternativeMaxSize.sub(orderAmount).toNum()) < 1; + + return isMaxLeverage || isAlternativeMax; +}; + +/** + * Gets the MAX_LEVERAGE_ORDER_SIZE as a BigNum with the same precision as the given amount + * @param orderAmount - The BigNum order amount to match precision with + * @returns BigNum representation of MAX_LEVERAGE_ORDER_SIZE + */ +export const getMaxLeverageOrderSize = (orderAmount: BigNum): BigNum => { + return new BigNum(MAX_LEVERAGE_ORDER_SIZE, orderAmount.precision); +}; + +/** + * Formats an order size for display, showing "Entire Position" if it's a max leverage order + * @param orderAmount - The BigNum order amount to format + * @param formatFn - Optional custom format function, defaults to prettyPrint() + * @returns Formatted string showing either "Entire Position" or the formatted amount + */ +export const formatOrderSize = ( + orderAmount: BigNum, + formatFn?: (amount: BigNum) => string +): string => { + if (isEntirePositionOrder(orderAmount)) { + return 'Entire Position'; + } + return formatFn ? formatFn(orderAmount) : orderAmount.prettyPrint(); +}; + +export { + getMarketTickSize, + getMarketTickSizeDecimals, + getMarketStepSize, + getMarketStepSizeDecimals, +}; diff --git a/common-ts/src/utils/validation.ts b/common-ts/src/utils/validation/address.ts similarity index 93% rename from common-ts/src/utils/validation.ts rename to common-ts/src/utils/validation/address.ts index b2562aeb..e5175569 100644 --- a/common-ts/src/utils/validation.ts +++ b/common-ts/src/utils/validation/address.ts @@ -1,8 +1,6 @@ -import { BigNum, PublicKey } from '@drift-labs/sdk'; +import { PublicKey } from '@drift-labs/sdk'; -export const isNotionalDust = (val: BigNum) => { - return !val.eqZero() && val.abs().toNum() < 0.01; -}; +export { isValidBase58 } from '../strings/parse'; export const isValidPublicKey = (str: string): boolean => { try { diff --git a/common-ts/src/utils/validation/index.ts b/common-ts/src/utils/validation/index.ts new file mode 100644 index 00000000..19008f6b --- /dev/null +++ b/common-ts/src/utils/validation/index.ts @@ -0,0 +1,3 @@ +export * from './address'; +export * from './notional'; +export * from './input'; diff --git a/common-ts/src/utils/validation/input.ts b/common-ts/src/utils/validation/input.ts new file mode 100644 index 00000000..87abbca3 --- /dev/null +++ b/common-ts/src/utils/validation/input.ts @@ -0,0 +1,38 @@ +import { SpotMarketConfig } from '@drift-labs/sdk'; + +const formatTokenInputCurried = + (setAmount: (amount: string) => void, spotMarketConfig: SpotMarketConfig) => + (newAmount: string) => { + if (isNaN(+newAmount)) return; + + if (newAmount === '') { + setAmount(''); + return; + } + + const lastChar = newAmount[newAmount.length - 1]; + + // if last char of string is a decimal point, don't format + if (lastChar === '.') { + setAmount(newAmount); + return; + } + + if (lastChar === '0') { + // if last char of string is a zero in the decimal places, cut it off if it exceeds precision + const numOfDigitsAfterDecimal = newAmount.split('.')[1]?.length ?? 0; + if (numOfDigitsAfterDecimal > spotMarketConfig.precisionExp.toNumber()) { + setAmount(newAmount.slice(0, -1)); + } else { + setAmount(newAmount); + } + return; + } + + const formattedAmount = Number( + (+newAmount).toFixed(spotMarketConfig.precisionExp.toNumber()) + ); + setAmount(formattedAmount.toString()); + }; + +export { formatTokenInputCurried }; diff --git a/common-ts/src/utils/validation/notional.ts b/common-ts/src/utils/validation/notional.ts new file mode 100644 index 00000000..528b7630 --- /dev/null +++ b/common-ts/src/utils/validation/notional.ts @@ -0,0 +1,5 @@ +import { BigNum } from '@drift-labs/sdk'; + +export const isNotionalDust = (val: BigNum) => { + return !val.eqZero() && val.abs().toNum() < 0.01; +}; diff --git a/common-ts/tests/utils/equalityChecks.test.ts b/common-ts/tests/utils/equalityChecks.test.ts index d3a70b81..ea749ebf 100644 --- a/common-ts/tests/utils/equalityChecks.test.ts +++ b/common-ts/tests/utils/equalityChecks.test.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { EQUALITY_CHECKS, PropertyAndType, -} from '../../src/utils/equalityChecks'; +} from '../../src/utils/core/equality'; import { BN, BigNum } from '@drift-labs/sdk'; import { OpenPosition } from '../../src/types'; diff --git a/common-ts/tests/utils/math.test.ts b/common-ts/tests/utils/math.test.ts index 1b099896..a04baf25 100644 --- a/common-ts/tests/utils/math.test.ts +++ b/common-ts/tests/utils/math.test.ts @@ -1,13 +1,13 @@ import { BN, L2OrderBook } from '@drift-labs/sdk'; import { - COMMON_MATH, numbersFitEvenly, roundToStepSizeIfLargeEnough, sortBnAsc, sortBnDesc, truncateInputToPrecision, valueIsBelowStepSize, -} from '../../src/utils/math'; +} from '../../src/utils/math/index'; +import { COMMON_MATH } from '../../src/_deprecated/common-math'; import { expect } from 'chai'; // Mock data setup diff --git a/common-ts/tests/utils/orders.test.ts b/common-ts/tests/utils/orders.test.ts index 84dee7e6..2f33c885 100644 --- a/common-ts/tests/utils/orders.test.ts +++ b/common-ts/tests/utils/orders.test.ts @@ -8,7 +8,7 @@ import { PRICE_PRECISION_EXP, PositionDirection, } from '@drift-labs/sdk'; -import { COMMON_UI_UTILS } from '../../src/common-ui-utils/commonUiUtils'; +import { COMMON_UI_UTILS } from '../../src/_deprecated/common-ui-utils'; import { ENUM_UTILS } from '../../src'; import { expect } from 'chai'; diff --git a/common-ts/tests/utils/stringUtils.test.ts b/common-ts/tests/utils/stringUtils.test.ts index 06263a57..a698f5f8 100644 --- a/common-ts/tests/utils/stringUtils.test.ts +++ b/common-ts/tests/utils/stringUtils.test.ts @@ -1,8 +1,8 @@ +import { COMMON_UI_UTILS } from '../../src/_deprecated/common-ui-utils'; import { - COMMON_UI_UTILS, abbreviateAddress, -} from '../../src/common-ui-utils/commonUiUtils'; -import { abbreviateAccountName } from '../../src/utils/strings'; + abbreviateAccountName, +} from '../../src/utils/strings'; import { expect } from 'chai'; describe('trimTrailingZeros', () => { it('trims trailing zeros after decimal', () => {