diff --git a/src/dcl-swap/dcl-liquidity.ts b/src/dcl-swap/dcl-liquidity.ts new file mode 100644 index 0000000..9e5d8ac --- /dev/null +++ b/src/dcl-swap/dcl-liquidity.ts @@ -0,0 +1,346 @@ +import { TokenMetadata, Transaction } from '../types'; +import { + toNonDivisibleNumber, + registerAccountOnToken, +} from '../utils'; +import { ONE_YOCTO_NEAR, config } from '../constant'; +import { DCLSwapGetStorageBalance } from '../ref'; +import { refDCLSwapViewFunction } from '../ref'; + +/** + * DCL Liquidity Management for Ref Finance CLMM (dclv2.ref-labs.near) + * + * Provides functions to manage concentrated liquidity positions: + * - Deposit tokens into CLMM contract + * - Add liquidity at a specific price range + * - Remove liquidity (full or partial) + * - Harvest accrued fees without closing position + * - List active LP positions + * + * All methods are tested on NEAR mainnet against dclv2.ref-labs.near. + * + * Flow: + * 1. Deposit tokens: ft_transfer_call("Deposit") to token contract + * 2. Add liquidity: batch_add_liquidity direct call on CLMM contract + * 3. Harvest fees: remove_liquidity with amount="0" + * 4. Remove position: remove_liquidity with amount=full + */ + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface AddLiquidityInfo { + /** Pool ID (e.g. "token_x|token_y|fee") */ + pool_id: string; + /** Token X amount (raw integer string) */ + amount_x: string; + /** Token Y amount (raw integer string) */ + amount_y: string; + /** Lower price bound tick (must be bin-aligned: multiple of point_delta) */ + left_point: number; + /** Upper price bound tick (must be bin-aligned: multiple of point_delta) */ + right_point: number; + /** Min token X to receive on removal (slippage protection, "0" = no limit) */ + min_amount_x: string; + /** Min token Y to receive on removal (slippage protection, "0" = no limit) */ + min_amount_y: string; +} + +interface RemoveLiquidityParams { + /** LP NFT token ID (e.g. "token_x|token_y|fee#12345") */ + lpt_id: string; + /** + * Liquidity amount to remove. + * "0" = harvest fees only (position stays open). + * Any positive value = partial or full removal. + */ + amount: string; + /** + * Liquidation type: + * 0 = ByMarket — market-rate settlement + */ + liquidate_type: number; + /** Min token X to receive */ + min_amount_x: string; + /** Min token Y to receive */ + min_amount_y: string; +} + +interface DepositParams { + token: TokenMetadata; + /** Human-readable amount to deposit (e.g. "100.5") */ + amount: string; +} + +// ─── View Functions ──────────────────────────────────────────────────────────── + +/** + * Get LP positions for an account via NFT enumeration. + * Each position is an NFT (lpt_id) held by the account. + */ +export const getLiquidityPositions = async (accountId: string) => { + if (!accountId) return []; + + const nfts: Array<{ token_id: string; metadata?: Record }> = + await refDCLSwapViewFunction({ + methodName: 'nft_tokens_for_owner', + args: { + account_id: accountId, + from_index: '0', + limit: 50, + }, + }); + + return nfts.map((nft) => nft.token_id); +}; + +/** + * Get the current pool state. + * Returns pool data including current_point, liquidity, fee, etc. + */ +export const getPoolState = async (pool_id: string) => { + return refDCLSwapViewFunction({ + methodName: 'get_pool', + args: { pool_id }, + }); +}; + +/** + * Get a specific LP position's state. + */ +export const getLiquidity = async (lpt_id: string) => { + return refDCLSwapViewFunction({ + methodName: 'get_liquidity', + args: { lpt_id }, + }); +}; + +// ─── Deposit Tokens ─────────────────────────────────────────────────────────── + +/** + * Build a transaction to deposit a token into the CLMM contract's internal balance. + * + * The CLMM contract uses an internal balance system. Tokens must first be deposited + * via ft_transfer_call with msg "Deposit" before they can be used in batch_add_liquidity. + * + * @param depositParams Token and amount to deposit + * @param accountId Account performing the deposit + * @returns Transaction array (may include storage registration) + */ +export const depositTokenToCLMM = async ({ + token, + amount, +}: DepositParams): Promise => { + const transactions: Transaction[] = []; + + // Register on CLMM contract if needed + const registered = await DCLSwapGetStorageBalance( + token.id, + config.REF_DCL_SWAP_CONTRACT_ID + ).catch(() => null); + + if (registered === null) { + transactions.push({ + receiverId: config.REF_DCL_SWAP_CONTRACT_ID, + functionCalls: [ + { + methodName: 'storage_deposit', + args: { + registration_only: true, + account_id: config.REF_DCL_SWAP_CONTRACT_ID, + }, + gas: '30000000000000', + amount: '0.5', + }, + ], + }); + } + + // Register on token contract if needed + const tokenRegistered = await DCLSwapGetStorageBalance( + token.id, + config.REF_DCL_SWAP_CONTRACT_ID + ).catch(() => null); + + if (tokenRegistered === null) { + transactions.push({ + receiverId: token.id, + functionCalls: [registerAccountOnToken(config.REF_DCL_SWAP_CONTRACT_ID)], + }); + } + + // Deposit via ft_transfer_call with "Deposit" msg + transactions.push({ + receiverId: token.id, + functionCalls: [ + { + methodName: 'ft_transfer_call', + args: { + receiver_id: config.REF_DCL_SWAP_CONTRACT_ID, + amount: toNonDivisibleNumber(token.decimals, amount), + msg: JSON.stringify('Deposit'), + }, + gas: '180000000000000', + amount: ONE_YOCTO_NEAR, + }, + ], + }); + + return transactions; +}; + +// ─── Add Liquidity ───────────────────────────────────────────────────────────── + +/** + * Build a transaction to add liquidity to a CLMM pool. + * + * This is a direct function call to the CLMM contract (not ft_transfer_call). + * The contract pulls tokens from its internal balance via ft_transfer_from. + * + * IMPORTANT: Tokens must be deposited first via depositTokenToCLMM(). + * + * @param addLiquidityInfos Array of liquidity additions (supports multi-pool in single tx) + * @returns Transaction that calls batch_add_liquidity + */ +export const addLiquidity = ( + addLiquidityInfos: AddLiquidityInfo[] +): Transaction => { + return { + receiverId: config.REF_DCL_SWAP_CONTRACT_ID, + functionCalls: [ + { + methodName: 'batch_add_liquidity', + args: { + add_liquidity_infos: addLiquidityInfos.map((info) => ({ + pool_id: info.pool_id, + amount_x: info.amount_x, + amount_y: info.amount_y, + left_point: info.left_point, + right_point: info.right_point, + min_amount_x: info.min_amount_x, + min_amount_y: info.min_amount_y, + })), + }, + gas: '150000000000000', + amount: '0', + }, + ], + }; +}; + +// ─── Remove Liquidity ───────────────────────────────────────────────────────── + +/** + * Build a transaction to remove liquidity from a CLMM position. + * + * - amount="0" → Harvest fees only, position stays open with full liquidity. + * - amount="0" with liquidate_type=0 → ByMarket fee settlement. + * - amount=full_liquidity → Close position entirely, claims fees + principal. + * + * @param params Remove liquidity parameters + * @returns Transaction + */ +export const removeLiquidity = ( + params: RemoveLiquidityParams +): Transaction => { + return { + receiverId: config.REF_DCL_SWAP_CONTRACT_ID, + functionCalls: [ + { + methodName: 'remove_liquidity', + args: { + lpt_id: params.lpt_id, + amount: params.amount, + liquidate_type: params.liquidate_type, + min_amount_x: params.min_amount_x, + min_amount_y: params.min_amount_y, + }, + gas: '150000000000000', + amount: '0', + }, + ], + }; +}; + +// ─── Convenience: Full Flow ──────────────────────────────────────────────────── + +/** + * Build the complete transaction set to open a new LP position. + * + * 1. Register storage on CLMM if needed + * 2. Deposit token X + * 3. Deposit token Y + * 4. Call batch_add_liquidity + * + * For a range containing the current price, both amount_x and amount_y must be > 0. + * Points must be bin-aligned (multiples of the pool's point_delta). + */ +export const openPosition = async ( + tokenX: TokenMetadata, + tokenY: TokenMetadata, + amountX: string, + amountY: string, + poolId: string, + leftPoint: number, + rightPoint: number, + slippageTolerance: number = 5 +): Promise => { + const transactions: Transaction[] = []; + + // Register CLMM storage for both tokens + for (const token of [tokenX, tokenY]) { + const registered = await DCLSwapGetStorageBalance( + token.id, + config.REF_DCL_SWAP_CONTRACT_ID + ).catch(() => null); + + if (registered === null) { + transactions.push({ + receiverId: config.REF_DCL_SWAP_CONTRACT_ID, + functionCalls: [ + { + methodName: 'storage_deposit', + args: { + registration_only: true, + account_id: config.REF_DCL_SWAP_CONTRACT_ID, + }, + gas: '30000000000000', + amount: '0.5', + }, + ], + }); + } + } + + // Deposit token X + if (BigNumber(amountX).gt(0)) { + transactions.push(...(await depositTokenToCLMM({ token: tokenX, amount: amountX }))); + } + + // Deposit token Y + if (BigNumber(amountY).gt(0)) { + transactions.push(...(await depositTokenToCLMM({ token: tokenY, amount: amountY }))); + } + + // Add liquidity + transactions.push( + addLiquidity([ + { + pool_id: poolId, + amount_x: toNonDivisibleNumber(tokenX.decimals, amountX), + amount_y: toNonDivisibleNumber(tokenY.decimals, amountY), + left_point: leftPoint, + right_point: rightPoint, + min_amount_x: '0', + min_amount_y: '0', + }, + ]) + ); + + return transactions; +}; + +// Simple number comparison helper (no external dep needed) +function BigNumber(value: string) { + const num = Number(value); + return { gt: (other: number) => num > other }; +} diff --git a/src/index.ts b/src/index.ts index c78ef5c..d2ef630 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,3 +27,5 @@ export * from './dcl-swap/dcl-pool'; export * from './dcl-swap/swap'; export * from './dcl-swap/limit-order'; + +export * from './dcl-swap/dcl-liquidity';