diff --git a/packages/dynamic-bonding-curve/CHANGELOG.md b/packages/dynamic-bonding-curve/CHANGELOG.md index ad0c5098..92098003 100644 --- a/packages/dynamic-bonding-curve/CHANGELOG.md +++ b/packages/dynamic-bonding-curve/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to the Dynamic Bonding Curve SDK will be documented in this file. +## [1.6.0] - 2026-10-02 + +### Added + +- `client.partner.createConfig2`. Pass `transferFeeParameters` for a base-mint transfer fee, or `null` when there is none. +- `TransferFeeWithheldAuthority` and `MigratedTransferFeeAuthorityOption`. +- Swap quotes account for Token-2022 transfer fees and return `includedTransferFeeAmountIn` and `excludedTransferFeeAmountOut`. `getSwapQuoteTransferFees` fetches the quote mint and current epoch. +- `getQuoteFromInputAmount` and `getQuoteFromOutputAmount` quote a `buildCurve` result before a pool exists. `client.pool.swapQuote2` quotes an existing pool and requires `currentPoint`. + +### Changed + +- `createConfigAndPool` and `createConfigAndPoolWithFirstBuy` always use `createConfig2`. Omitting `transferFeeParameters` is the same as `null`. +- A base transfer fee, or a quote mint with a non-zero transfer fee or a live transfer fee config authority, requires a constant token supply, no locked vesting, `MigrationFeeOption.Customizable`, and `MigratedCollectFeeMode.Compounding`. The base fee is Token-2022 only and at most 10%. +- `MigratedCollectFeeMode.Compounding` allows `compoundingFeeBps` of `0`. +- Curve builders reserve the program migration base, `ceil(migrationQuoteThreshold * (100 - migrationFee.feePercentage) / 100)`. `buildCurveWithLiquidityWeights` and `buildCurveWithCustomSqrtPrices` use constant product for `Compounding`, and concentrated liquidity for `QuoteToken` and `OutputToken`. +- `buildCurveWithTwoSegments` and `buildCurveWithMidPrice` keep a fractional `percentageSupplyOnMigration` when sizing the migration base. +- Liquidity vesting frequency is `floor(totalDuration / numberOfPeriods)`. +- Exponential fee schedulers, including the migrated market-cap scheduler, choose `reductionFactor` with the program's Q64 decay so the last period stays at or above the requested ending fee. +- Rate-limiter validation checks the fee at `u64::MAX`. +- Swap quotes reject amounts outside the program's u64 and u128 bounds. Insufficient-liquidity quotes use the program error text. +- Synced the DAMM v2 IDL and `cp_amm.so` fixture to cp_amm 0.2.5. +- `calculateFeeSchedulerEndingBaseFeeBps` uses the program's integer conversion, `numerator * 10_000 / FEE_DENOMINATOR`. + +### Deprecated + +- `client.partner.createConfig` in favour of `createConfig2`. `createConfig` and `createConfigWithTransferHook` reject a quote mint with a non-zero transfer fee or a live transfer fee config authority. + +### Breaking Changes + +- Swap quotes for a Token-2022 quote mint require `quoteMint` and `currentEpoch`. +- Removed `quoteSwap2` and `SwapQuoteConfig`. Quote an existing pool with `swapQuote2`. Quote a `buildCurve` result with `getQuoteFromInputAmount` or `getQuoteFromOutputAmount`. `getQuoteFrom*` takes `ConfigParameters` and does not accept a decoded `PoolConfig`. +- `getMigratedPoolFeeParams` throws for `MigrationFeeOption.Customizable` when `migratedPoolFee.poolFeeBps` is not set. +- `swap`, `swap2`, `claimPartnerTradingFee`, and `claimCreatorTradingFee` reject transfer-hook pools. Use `swap2WithTransferHook`, `claimPartnerTradingFee2`, and `claimCreatorTradingFee2`. Standard pool creation rejects a transfer-hook config, and transfer-hook pool creation rejects a standard config. + ## [1.5.13] - 2026-09-24 ### Changed diff --git a/packages/dynamic-bonding-curve/docs.md b/packages/dynamic-bonding-curve/docs.md index 7b7a6002..10d9b3f0 100644 --- a/packages/dynamic-bonding-curve/docs.md +++ b/packages/dynamic-bonding-curve/docs.md @@ -4,6 +4,7 @@ - [Partner Functions](#partner-functions) - [createConfig](#createConfig) + - [createConfig2](#createConfig2) - [createConfigWithTransferHook](#createConfigWithTransferHook) - [createConfigAndPool](#createConfigAndPool) - [createConfigAndPoolWithTransferHook](#createConfigAndPoolWithTransferHook) @@ -78,6 +79,9 @@ - [getPoolsFeesByCreator](#getPoolsFeesByCreator) - [getDammV1MigrationMetadata](#getDammV1MigrationMetadata) - [getTokenBadge](#getTokenBadge) + - [isTransferHookConfig](#isTransferHookConfig) + - [isTransferHookPool](#isTransferHookPool) + - [getConfigWithTransferHook](#getConfigWithTransferHook) - [Helper Functions](#helper-functions) - [deriveDbcPoolAddress](#deriveDbcPoolAddress) @@ -173,7 +177,7 @@ interface CreateConfigParams { collectFeeMode: number // 0: QuoteToken, 1: OutputToken, 2: Compounding dynamicFee: number // 0: Disabled, 1: Enabled poolFeeBps: number // The pool fee in basis points. Minimum 10, Maximum 1000 bps. - compoundingFeeBps?: number // Required when collectFeeMode = 2 (Compounding), otherwise must be 0 + compoundingFeeBps?: number // 0 to 10_000 when collectFeeMode = 2 (Compounding). 0 collects the trading fee in the quote token. Otherwise must be 0 } poolCreationFee: BN // The pool creation fee partnerLiquidityVestingInfo: { @@ -330,7 +334,9 @@ When creating a new configuration for a dynamic bonding curve, several validatio **Migration and Token Type** - New configs must use DAMM v2 (`MigrationOption.MET_DAMM_V2`). DAMM v1 migration is deprecated for new configs and new pools. Existing DAMM v1 pools can still migrate. -- Token-badge remaining account is required when the quote mint is not permissionless-supported. Use `deriveTokenBadgeAddress(quoteMint)` and pass it as `tokenBadge`. A token badge does not allow a non-zero transfer fee. +- Token-badge remaining account is required when the quote mint is not permissionless-supported. Use `deriveTokenBadgeAddress(quoteMint)` and pass it as `tokenBadge`. +- A quote mint with a zero transfer fee and a revoked transfer fee config authority is permissionless-supported. A non-zero transfer fee or a live transfer fee config authority is only accepted by `createConfig2`, and still needs a token badge when the mint is not otherwise permissionless-supported. +- `createConfig` and `createConfigWithTransferHook` reject those quote mints. Use `createConfig2`. **Activation Type** @@ -423,6 +429,66 @@ When creating a new configuration for a dynamic bonding curve, several validatio --- +### createConfig2 + +Creates a config through `create_config2`. This is the instruction that accepts a base-mint transfer fee and a quote mint with a non-zero transfer fee or a live transfer fee config authority. + +`createConfig` is deprecated. It still creates a config with no base transfer fee, and the program rejects the quote mints above. + +**Function** + +```typescript +async createConfig2(params: CreateConfig2Params): Promise +``` + +**Parameters** + +`CreateConfig2Params` is `CreateConfigParams` plus: + +```typescript +transferFeeParameters?: { + transferFeeBasisPoints: number // 0, or 1 to 1000 (10%) for a Token-2022 base mint + withheldAuthority: number // 0: Partner, 1: Creator. Must be 0 when the fee is 0 + migratedTransferFeeAuthorityOption: number // 0: Immutable, 1: RevokeZeroFee, 2: Creator, 3: Partner. Must be 0 when the fee is 0 +} | null +``` + +Pass `null` or omit the field when the base mint has no transfer fee, including when only the quote mint has one. + +`createConfigAndPool` and `createConfigAndPoolWithFirstBuy` always call `createConfig2`. Omitting `transferFeeParameters` is the same as passing `null`. + +**Returns** + +- A transaction that can be signed and sent to the network. The `config` account must sign. + +**Example** + +```typescript +const transaction = await client.partner.createConfig2({ + payer, + config: configKeypair.publicKey, + feeClaimer, + leftoverReceiver, + quoteMint, + ...configParameters, + transferFeeParameters: { + transferFeeBasisPoints: 250, + withheldAuthority: TransferFeeWithheldAuthority.Partner, + migratedTransferFeeAuthorityOption: + MigratedTransferFeeAuthorityOption.Immutable, + }, +}) +``` + +**Notes** + +- A non-zero `transferFeeBasisPoints` requires `tokenType` Token-2022 and cannot exceed 1000 bps. The base mint is created with that fee and `maximumFee = u64::MAX`. +- When the base mint has a transfer fee, or the quote mint has a non-zero transfer fee or a live transfer fee config authority, the config must use a constant token supply (`preMigrationTokenSupply == postMigrationTokenSupply`), no locked vesting, `MigrationFeeOption.Customizable`, and `MigratedCollectFeeMode.Compounding`. +- A base or quote transfer fee lowers what DAMM v2 receives at migration. The program keeps the migration price by depositing proportionally less of the other token, so the migrated pool starts with less liquidity. The amount not deposited is added to the protocol migration fee. +- Swap quotes read the base transfer fee from `config.transferFeeBasisPoints`. For a Token-2022 quote mint they throw unless `quoteMint` and `currentEpoch` are passed. `getSwapQuoteTransferFees(connection, config)` fetches both. See [swapQuote2](#swapQuote2). + +--- + ### createConfigWithTransferHook Creates a Token-2022 transfer-hook config. This is the transfer-hook variant of `createConfig` and stores the transfer-hook program in the config account. @@ -547,7 +613,7 @@ interface CreateConfigAndPoolParams { collectFeeMode: number // 0: QuoteToken, 1: OutputToken, 2: Compounding dynamicFee: number // 0: Disabled, 1: Enabled poolFeeBps: number // The pool fee in basis points. Minimum 10, Maximum 1000 bps. - compoundingFeeBps?: number // Required when collectFeeMode = 2 (Compounding), otherwise must be 0 + compoundingFeeBps?: number // 0 to 10_000 when collectFeeMode = 2 (Compounding). 0 collects the trading fee in the quote token. Otherwise must be 0 } migratedPoolBaseFeeMode: number // 0: FeeTimeSchedulerLinear, 1: FeeTimeSchedulerExponential, 3: FeeMarketCapSchedulerLinear, 4: FeeMarketCapSchedulerExponential (defaults to FeeTimeSchedulerLinear) migratedPoolMarketCapFeeSchedulerParams: { @@ -845,7 +911,7 @@ interface CreateConfigAndPoolWithFirstBuyParams { collectFeeMode: number // 0: QuoteToken, 1: OutputToken, 2: Compounding dynamicFee: number // 0: Disabled, 1: Enabled poolFeeBps: number // The pool fee in basis points. Minimum 10, Maximum 1000 bps. - compoundingFeeBps?: number // Required when collectFeeMode = 2 (Compounding), otherwise must be 0 + compoundingFeeBps?: number // 0 to 10_000 when collectFeeMode = 2 (Compounding). 0 collects the trading fee in the quote token. Otherwise must be 0 } migratedPoolBaseFeeMode: number // 0: FeeTimeSchedulerLinear, 1: FeeTimeSchedulerExponential, 3: FeeMarketCapSchedulerLinear, 4: FeeMarketCapSchedulerExponential (defaults to FeeTimeSchedulerLinear) migratedPoolMarketCapFeeSchedulerParams: { @@ -1455,7 +1521,7 @@ interface BuildCurveParams { collectFeeMode: MigratedCollectFeeMode // 0: QuoteToken, 1: OutputToken, 2: Compounding dynamicFee: DammV2DynamicFeeMode // 0: Disabled, 1: Enabled poolFeeBps: number // The pool fee in basis points - compoundingFeeBps?: number // Required when collectFeeMode = 2 (Compounding), otherwise must be 0 + compoundingFeeBps?: number // 0 to 10_000 when collectFeeMode = 2 (Compounding). 0 collects the trading fee in the quote token. Otherwise must be 0 baseFeeMode?: DammV2BaseFeeMode // 0: FeeTimeSchedulerLinear, 1: FeeTimeSchedulerExponential, 3: FeeMarketCapSchedulerLinear, 4: FeeMarketCapSchedulerExponential marketCapFeeSchedulerParams?: { // Configure for market cap-based fee scheduling @@ -1690,7 +1756,7 @@ const transaction = await client.partner.createConfig({ - `priceMultiple` is required and must be greater than 1. - The SDK derives on-chain `sqrtPriceStepBps` from `priceMultiple` and `numberOfPeriod`. - `poolFeeBps` must be greater than 0. -- If `migratedPoolFee.collectFeeMode = MigratedCollectFeeMode.Compounding` (2), `compoundingFeeBps` must be `> 0` and `<= 10_000`; otherwise `compoundingFeeBps` must be `0`. +- If `migratedPoolFee.collectFeeMode = MigratedCollectFeeMode.Compounding` (2), `compoundingFeeBps` must be `>= 0` and `<= 10_000`. `0` collects the trading fee in the quote token. Otherwise `compoundingFeeBps` must be `0`. - For DAMM V1 migration, all `migratedPoolFee` parameters are ignored and defaults are used. --- @@ -2458,6 +2524,7 @@ const quote = await client.pool.swapQuote({ - The `amountIn` is the amount of tokens you want to swap, denominated in the smallest unit and token decimals. (e.g., lamports for SOL). - The `slippageBps` parameter protects against slippage. Set it to a value slightly lower than the expected output. - The `referralTokenAccount` parameter is an optional token account. If provided, the referral fee will be applied to the transaction. +- `swapQuote` applies transfer fees the same way as `swapQuote2` for exact-in. `minimumAmountOut` is based on `excludedTransferFeeAmountOut`. --- @@ -2634,7 +2701,7 @@ const transaction = await client.pool.swap2WithTransferHook({ ### swapQuote2 -Gets the exact swap out quotation in between quote and base swaps with specific swap modes (ExactIn, ExactOut, PartialFill). +Gets the exact swap out quotation in between quote and base swaps with specific swap modes (ExactIn, ExactOut, PartialFill). `currentPoint` is required. It is the chain's current point and is not taken from `activationPoint`. **Function** @@ -2707,12 +2774,30 @@ const quote = await client.pool.swapQuote2({ - The `amountIn` is the amount of tokens you want to swap, denominated in the smallest unit and token decimals. (e.g., lamports for SOL). - The `slippageBps` parameter protects against slippage. Set it to a value slightly lower than the expected output. - The `referralTokenAccount` parameter is an optional token account. If provided, the referral fee will be applied to the transaction. +- Transfer fees are applied before the curve on the input mint and after the curve on the output mint. `amountIn` and `amountOut` are the amounts the user pays and receives. `includedTransferFeeAmountIn` is what the user pays. `excludedTransferFeeAmountOut` is what the user receives. `minimumAmountOut` and `maximumAmountIn` use those amounts. +- A base transfer fee is read from `config.transferFeeBasisPoints`. Pass `baseMint` to use the mint's epoch fee instead, or `baseTransferFeeBasisPoints` before the mint exists. `currentEpoch` is required when either mint is set. +- When `config.quoteTokenFlag` is Token-2022, `quoteMint` and `currentEpoch` are required, because the program applies the quote mint's fee for the current epoch. `getSwapQuoteTransferFees` fetches both and returns an empty object for an SPL Token quote mint: + +```typescript +const quote = client.pool.swapQuote2({ + virtualPool: virtualPoolState, + config: poolConfigState, + swapBaseForQuote: false, + amountIn, + slippageBps: 50, + hasReferral: false, + eligibleForFirstSwapWithMinFee: false, + currentPoint, + swapMode: SwapMode.ExactIn, + ...(await getSwapQuoteTransferFees(connection, poolConfigState)), +}) +``` --- ### getQuoteFromInputAmount -Quotes a launch-state swap from an input amount before a pool exists. This is useful when a builder has a curve/config but has not deployed the pool yet. +Quotes a swap from an input amount before a pool exists. `config` is a `ConfigParameters` value, which is what `buildCurve` returns and what `createConfig` takes. The quote is the pool `initialize_pool` would write at `currentPoint`: sqrt price is `sqrtStartPrice`, volatility is zero, and `hasSwap` is 0. Elapsed time is 0, so a fee scheduler is at the cliff fee. The same function is exported from the package. **Function** @@ -2723,38 +2808,17 @@ getQuoteFromInputAmount(params: SimulatedQuoteFromInputAmountParams): SwapQuote2 **Parameters** ```typescript -interface SwapQuoteConfig { - poolFees: { - baseFee: { - cliffFeeNumerator: BN - firstFactor: number - secondFactor: BN - thirdFactor: BN - baseFeeMode: number - } - dynamicFee?: { - initialized?: number - binStep: number - variableFeeControl: number - } | null - } - collectFeeMode: number - sqrtStartPrice: BN - migrationQuoteThreshold: BN - curve: Array<{ sqrtPrice: BN; liquidity: BN }> - migrationSqrtPrice?: BN -} - type SimulatedQuoteFromInputAmountParams = { - config: SwapQuoteConfig + config: ConfigParameters swapBaseForQuote: boolean // True for base->quote, false for quote->base amountIn: BN swapMode?: SwapMode.ExactIn | SwapMode.PartialFill slippageBps?: number hasReferral?: boolean - currentPoint?: BN // Elapsed slot/timestamp since launch; defaults to 0 + currentPoint?: BN // Initialization point. Omit to use 0 eligibleForFirstSwapWithMinFee?: boolean -} + quoteTokenFlag?: number // TokenType.Token2022 when the quote mint is Token-2022 +} & QuoteTransferFees ``` **Returns** @@ -2779,16 +2843,17 @@ const quote = client.pool.getQuoteFromInputAmount({ **Notes** -- `config` can be a `buildCurve` output or a fetched pool config from `client.state.getPoolConfig(...)`. -- If `migrationSqrtPrice` is omitted, it is derived from `migrationQuoteThreshold`, `sqrtStartPrice`, and `curve`. -- `SwapMode.ExactIn` throws if the full input cannot be absorbed by the launch-state curve. Use `SwapMode.PartialFill` to fill up to the available curve liquidity and inspect `amountLeft`. -- `currentPoint` is only needed when simulating fee-scheduler or rate-limiter behavior after launch. It defaults to `0`, which represents the launch/cliff fee. +- `migrationSqrtPrice` is computed the way `process_create_config` computes it, from `migrationQuoteThreshold`, `sqrtStartPrice`, and `curve`. +- `SwapMode.ExactIn` throws if the full input cannot be absorbed by the curve. Use `SwapMode.PartialFill` to fill up to the available curve liquidity and inspect `amountLeft`. +- Omit `currentPoint` to use 0 for both the activation point and the current point. A caller-supplied `currentPoint` is used for both, so elapsed time stays 0. +- Pass `baseTransferFeeBasisPoints` when `createConfig2` sets a base transfer fee. Pass `quoteTokenFlag: TokenType.Token2022` for a Token-2022 quote mint; `quoteMint` and `currentEpoch` are then required. +- An existing pool is quoted with `swapQuote2` once the pool exists. This function does not accept a decoded `PoolConfig`. --- ### getQuoteFromOutputAmount -Quotes a launch-state exact-out swap before a pool exists. This is useful when a builder wants to know how much input is required to receive a target output amount. +Quotes an exact-out swap before a pool exists, using the same initial pool as `getQuoteFromInputAmount`. The same function is exported from the package. **Function** @@ -2800,14 +2865,15 @@ getQuoteFromOutputAmount(params: SimulatedQuoteFromOutputAmountParams): SwapQuot ```typescript type SimulatedQuoteFromOutputAmountParams = { - config: SwapQuoteConfig + config: ConfigParameters swapBaseForQuote: boolean // True for base->quote, false for quote->base amountOut: BN slippageBps?: number hasReferral?: boolean - currentPoint?: BN // Elapsed slot/timestamp since launch; defaults to 0 + currentPoint?: BN // Initialization point. Omit to use 0 eligibleForFirstSwapWithMinFee?: boolean -} + quoteTokenFlag?: number +} & QuoteTransferFees ``` **Returns** @@ -2817,10 +2883,12 @@ type SimulatedQuoteFromOutputAmountParams = { **Example** ```typescript -const config = await client.state.getPoolConfig(configAddress) +const curveConfig = buildCurve({ + // token, fee, migration, liquidity distribution, and activation params +}) const quote = client.pool.getQuoteFromOutputAmount({ - config, + config: curveConfig, swapBaseForQuote: false, // quote -> base amountOut: new BN(1_000_000_000_000), slippageBps: 100, @@ -2832,8 +2900,7 @@ console.log('maximum input with slippage:', quote.maximumAmountIn?.toString()) **Notes** -- This helper uses the same exact-out quote math as `swapQuote2` with `SwapMode.ExactOut`, but simulates a fresh pool at `sqrtStartPrice`. -- It throws if the requested output is not fillable by the launch-state curve. +- It throws if the requested output is not fillable by the curve. - Amounts are returned in token base units. Use token decimals when displaying human-readable values. --- @@ -4398,6 +4465,92 @@ const tokenBadge = await client.state.getTokenBadge(quoteMint) --- +### isTransferHookConfig + +Checks whether a config account is a transfer-hook config. + +**Function** + +```typescript +async isTransferHookConfig(configAddress: PublicKey | string): Promise +``` + +**Parameters** + +```typescript +configAddress: PublicKey | string // The address of the config account +``` + +**Returns** + +- `true` if the account is a `ConfigWithTransferHook`, `false` otherwise or if the account does not exist. + +**Example** + +```typescript +const isTransferHookConfig = + await client.state.isTransferHookConfig(configAddress) +``` + +--- + +### isTransferHookPool + +Checks whether a pool account is a transfer-hook pool. + +**Function** + +```typescript +async isTransferHookPool(poolAddress: PublicKey | string): Promise +``` + +**Parameters** + +```typescript +poolAddress: PublicKey | string // The address of the pool account +``` + +**Returns** + +- `true` if the account is a `TransferHookPool`, `false` otherwise or if the account does not exist. + +**Example** + +```typescript +const isTransferHookPool = await client.state.isTransferHookPool(poolAddress) +``` + +--- + +### getConfigWithTransferHook + +Fetches a transfer-hook config account, including its transfer hook program. + +**Function** + +```typescript +async getConfigWithTransferHook(configAddress: PublicKey | string): Promise +``` + +**Parameters** + +```typescript +configAddress: PublicKey | string // The address of the transfer-hook config account +``` + +**Returns** + +- The `ConfigWithTransferHook` account (`config` and `transferHookProgram`), or `null` if it does not exist. + +**Example** + +```typescript +const configWithTransferHook = + await client.state.getConfigWithTransferHook(configAddress) +``` + +--- + ### getPoolFeeMetrics Gets the fee metrics for a specific pool. diff --git a/packages/dynamic-bonding-curve/package.json b/packages/dynamic-bonding-curve/package.json index 960a3b07..5324273d 100644 --- a/packages/dynamic-bonding-curve/package.json +++ b/packages/dynamic-bonding-curve/package.json @@ -1,6 +1,6 @@ { "name": "@meteora-ag/dynamic-bonding-curve-sdk", - "version": "1.5.13", + "version": "1.6.0", "main": "dist/index.cjs", "module": "dist/index.js", "source": "src/index.ts", diff --git a/packages/dynamic-bonding-curve/src/constants.ts b/packages/dynamic-bonding-curve/src/constants.ts index aa49ac80..56ba8f55 100644 --- a/packages/dynamic-bonding-curve/src/constants.ts +++ b/packages/dynamic-bonding-curve/src/constants.ts @@ -13,6 +13,7 @@ export const ONE_Q64 = new BN(1).shln(RESOLUTION) export const FEE_DENOMINATOR = 1_000_000_000 export const MAX_BASIS_POINT = 10_000 +export const MAX_BASE_TRANSFER_FEE_BPS = 1_000 export const U16_MAX = 65_535 export const U24_MAX = 16_777_215 @@ -52,6 +53,12 @@ export const HOST_FEE_PERCENT = 20 // 20% // Swap Buffer export const SWAP_BUFFER_PERCENTAGE = 25 // 25% +// Protocol liquidity migration fee +export const PROTOCOL_LIQUIDITY_MIGRATION_FEE_BPS = 20 // 0.2% + +// DAMM v2 locks this liquidity in a compounding pool +export const DAMM_V2_COMPOUNDING_DEAD_LIQUIDITY = new BN(100).shln(64) + // Migration Fee export const MAX_MIGRATION_FEE_PERCENTAGE = 99 // 99% // previously 50% export const MAX_CREATOR_MIGRATION_FEE_PERCENTAGE = 100 // 100% diff --git a/packages/dynamic-bonding-curve/src/helpers/buildCurve.ts b/packages/dynamic-bonding-curve/src/helpers/buildCurve.ts index 3edee74b..aa53c6f0 100644 --- a/packages/dynamic-bonding-curve/src/helpers/buildCurve.ts +++ b/packages/dynamic-bonding-curve/src/helpers/buildCurve.ts @@ -10,6 +10,16 @@ import { BuildCurveWithMidPriceParams, BuildCurveWithCustomSqrtPricesParams, TokenDecimal, + type BaseFee, + type BaseFeeParams, + type CollectFeeMode, + type LockedVestingParameters, + type MigrationFee, + type MigratedPoolFeeResult, + type ActivationType, + type MigrationOption, + type TokenType, + type TokenAuthorityOption, } from '../types' import { DEFAULT_LIQUIDITY_VESTING_INFO_PARAMS, @@ -18,6 +28,9 @@ import { import { getSqrtPriceFromPrice, getMigrationBaseToken, + getMigrationBaseWeight, + getMigrationQuoteAmountFromThreshold, + getMigrationQuoteAmountFromMigrationBase, getTotalVestingAmount, getFirstCurve, getTotalSupplyFromCurve, @@ -37,7 +50,88 @@ import { getLiquidityVestingInfoParams, } from './common' import { getInitialLiquidityFromDeltaBase } from '../math/curve' -import { convertDecimalToBN, convertToLamports, fromDecimalToBN } from './utils' +import { convertToLamports, fromDecimalToBN } from './utils' + +function toConfigParameters(params: { + baseFee: BaseFee + baseFeeParams: BaseFeeParams + dynamicFeeEnabled: boolean + collectFeeMode: CollectFeeMode + migrationOption: MigrationOption + activationType: ActivationType + tokenType: TokenType + tokenBaseDecimal: number + partnerLiquidityPercentage: number + partnerPermanentLockedLiquidityPercentage: number + creatorLiquidityPercentage: number + creatorPermanentLockedLiquidityPercentage: number + migrationQuoteThreshold: BN + sqrtStartPrice: BN + lockedVestingParams: LockedVestingParameters + migratedPoolFeeResult: MigratedPoolFeeResult + totalSupply: BN + creatorTradingFeePercentage: number + tokenAuthorityOption: TokenAuthorityOption + migrationFee: MigrationFee + poolCreationFeeInLamports: BN + partnerLiquidityVestingInfo: ReturnType< + typeof getLiquidityVestingInfoParams + > + creatorLiquidityVestingInfo: ReturnType< + typeof getLiquidityVestingInfoParams + > + enableFirstSwapWithMinFee: boolean + curve: ConfigParameters['curve'] +}): ConfigParameters { + const endingOrBaseFeeBps = + params.baseFeeParams.baseFeeMode === BaseFeeMode.RateLimiter + ? params.baseFeeParams.rateLimiterParam.baseFeeBps + : params.baseFeeParams.feeSchedulerParam.endingFeeBps + + return { + poolFees: { + baseFee: { ...params.baseFee }, + dynamicFee: params.dynamicFeeEnabled + ? getDynamicFeeParams(endingOrBaseFeeBps) + : null, + }, + collectFeeMode: params.collectFeeMode, + migrationOption: params.migrationOption, + activationType: params.activationType, + tokenType: params.tokenType, + tokenDecimal: params.tokenBaseDecimal, + partnerLiquidityPercentage: params.partnerLiquidityPercentage, + partnerPermanentLockedLiquidityPercentage: + params.partnerPermanentLockedLiquidityPercentage, + creatorLiquidityPercentage: params.creatorLiquidityPercentage, + creatorPermanentLockedLiquidityPercentage: + params.creatorPermanentLockedLiquidityPercentage, + migrationQuoteThreshold: params.migrationQuoteThreshold, + sqrtStartPrice: params.sqrtStartPrice, + lockedVesting: params.lockedVestingParams, + migrationFeeOption: params.migratedPoolFeeResult.migrationFeeOption, + tokenSupply: { + preMigrationTokenSupply: params.totalSupply, + postMigrationTokenSupply: params.totalSupply, + }, + creatorTradingFeePercentage: params.creatorTradingFeePercentage, + tokenUpdateAuthority: params.tokenAuthorityOption, + migrationFee: params.migrationFee, + migratedPoolFee: params.migratedPoolFeeResult.migratedPoolFee, + poolCreationFee: params.poolCreationFeeInLamports, + partnerLiquidityVestingInfo: params.partnerLiquidityVestingInfo, + creatorLiquidityVestingInfo: params.creatorLiquidityVestingInfo, + migratedPoolBaseFeeMode: + params.migratedPoolFeeResult.migratedPoolBaseFeeMode, + migratedPoolMarketCapFeeSchedulerParams: + params.migratedPoolFeeResult + .migratedPoolMarketCapFeeSchedulerParams, + enableFirstSwapWithMinFee: params.enableFirstSwapWithMinFee, + compoundingFeeBps: params.migratedPoolFeeResult.compoundingFeeBps, + padding: [], + curve: params.curve, + } +} /** * Build config parameters for a single curve segment constant product curve. @@ -169,14 +263,16 @@ export function buildCurve(params: BuildCurveParams): ConfigParameters { tokenQuoteDecimal ) - const migrationQuoteAmountInLamport = fromDecimalToBN( - migrationQuoteAmount.mul(new Decimal(10 ** tokenQuoteDecimal)) + const migrationQuoteAmountInLamport = getMigrationQuoteAmountFromThreshold( + migrationQuoteThresholdInLamport, + migrationFee.feePercentage ) const migrationBaseAmount = getMigrationBaseToken( migrationQuoteAmountInLamport, migrateSqrtPrice, - migrationOption + migrationOption, + migratedPoolFeeResult.migratedPoolFee.collectFeeMode ) const totalVestingAmount = getTotalVestingAmount(lockedVestingParams) @@ -188,10 +284,8 @@ export function buildCurve(params: BuildCurveParams): ConfigParameters { const { sqrtStartPrice, curve } = getFirstCurve( migrateSqrtPrice, - migrationBaseAmount, swapAmount, - migrationQuoteThresholdInLamport, - migrationFee.feePercentage + migrationQuoteThresholdInLamport ) const totalDynamicSupply = getTotalSupplyFromCurve( @@ -201,7 +295,8 @@ export function buildCurve(params: BuildCurveParams): ConfigParameters { lockedVestingParams, migrationOption, totalLeftover, - migrationFee.feePercentage + migrationFee.feePercentage, + migratedPoolFeeResult.migratedPoolFee.collectFeeMode ) const remainingAmount = totalSupply.sub(totalDynamicSupply) @@ -219,52 +314,33 @@ export function buildCurve(params: BuildCurveParams): ConfigParameters { }) } - const instructionParams: ConfigParameters = { - poolFees: { - baseFee: { - ...baseFee, - }, - dynamicFee: dynamicFeeEnabled - ? getDynamicFeeParams( - baseFeeParams.baseFeeMode === BaseFeeMode.RateLimiter - ? baseFeeParams.rateLimiterParam.baseFeeBps - : baseFeeParams.feeSchedulerParam.endingFeeBps - ) - : null, - }, + return toConfigParameters({ + baseFee, + baseFeeParams, + dynamicFeeEnabled, collectFeeMode, migrationOption, activationType, tokenType, - tokenDecimal: tokenBaseDecimal, + tokenBaseDecimal, partnerLiquidityPercentage, partnerPermanentLockedLiquidityPercentage, creatorLiquidityPercentage, creatorPermanentLockedLiquidityPercentage, migrationQuoteThreshold: migrationQuoteThresholdInLamport, - sqrtStartPrice, - lockedVesting: lockedVestingParams, - migrationFeeOption: migratedPoolFeeResult.migrationFeeOption, - tokenSupply: { - preMigrationTokenSupply: totalSupply, - postMigrationTokenSupply: totalSupply, - }, + sqrtStartPrice: sqrtStartPrice, + lockedVestingParams, + migratedPoolFeeResult, + totalSupply, creatorTradingFeePercentage, - tokenUpdateAuthority: tokenAuthorityOption, + tokenAuthorityOption, migrationFee, - migratedPoolFee: migratedPoolFeeResult.migratedPoolFee, - poolCreationFee: poolCreationFeeInLamports, + poolCreationFeeInLamports, partnerLiquidityVestingInfo, creatorLiquidityVestingInfo, - migratedPoolBaseFeeMode: migratedPoolFeeResult.migratedPoolBaseFeeMode, - migratedPoolMarketCapFeeSchedulerParams: - migratedPoolFeeResult.migratedPoolMarketCapFeeSchedulerParams, enableFirstSwapWithMinFee, - compoundingFeeBps: migratedPoolFeeResult.compoundingFeeBps, - padding: [], curve, - } - return instructionParams + }) } /** @@ -461,9 +537,9 @@ export function buildCurveWithTwoSegments( baseFeeParams ) - const migrationBaseSupply = new BN(totalTokenSupply) - .mul(new BN(percentageSupplyOnMigration)) - .div(new BN(100)) + const migrationBaseSupply = new Decimal(totalTokenSupply) + .mul(new Decimal(percentageSupplyOnMigration)) + .div(new Decimal(100)) const totalSupply = convertToLamports(totalTokenSupply, tokenBaseDecimal) const migrationQuoteAmount = getMigrationQuoteAmount( @@ -476,16 +552,15 @@ export function buildCurveWithTwoSegments( new Decimal(migrationFee.feePercentage) ) - const migrationPrice = migrationQuoteAmount.div( - new Decimal(migrationBaseSupply.toString()) - ) + const migrationPrice = migrationQuoteAmount.div(migrationBaseSupply) const migrationQuoteThresholdInLamport = fromDecimalToBN( migrationQuoteThreshold.mul(new Decimal(10 ** tokenQuoteDecimal)) ) - const migrationQuoteAmountInLamport = fromDecimalToBN( - migrationQuoteAmount.mul(new Decimal(10 ** tokenQuoteDecimal)) + const migrationQuoteAmountInLamport = getMigrationQuoteAmountFromThreshold( + migrationQuoteThresholdInLamport, + migrationFee.feePercentage ) const migrateSqrtPrice = getSqrtPriceFromPrice( @@ -497,7 +572,8 @@ export function buildCurveWithTwoSegments( const migrationBaseAmount = getMigrationBaseToken( migrationQuoteAmountInLamport, migrateSqrtPrice, - migrationOption + migrationOption, + migratedPoolFeeResult.migratedPoolFee.collectFeeMode ) const totalVestingAmount = getTotalVestingAmount(lockedVestingParams) @@ -562,7 +638,8 @@ export function buildCurveWithTwoSegments( lockedVestingParams, migrationOption, totalLeftover, - migrationFee.feePercentage + migrationFee.feePercentage, + migratedPoolFeeResult.migratedPoolFee.collectFeeMode ) if (totalDynamicSupply.gt(totalSupply)) { @@ -573,52 +650,33 @@ export function buildCurveWithTwoSegments( } } - const instructionParams: ConfigParameters = { - poolFees: { - baseFee: { - ...baseFee, - }, - dynamicFee: dynamicFeeEnabled - ? getDynamicFeeParams( - baseFeeParams.baseFeeMode === BaseFeeMode.RateLimiter - ? baseFeeParams.rateLimiterParam.baseFeeBps - : baseFeeParams.feeSchedulerParam.endingFeeBps - ) - : null, - }, - activationType, + return toConfigParameters({ + baseFee, + baseFeeParams, + dynamicFeeEnabled, collectFeeMode, migrationOption, + activationType, tokenType, - tokenDecimal: tokenBaseDecimal, - migrationQuoteThreshold: migrationQuoteThresholdInLamport, + tokenBaseDecimal, partnerLiquidityPercentage, partnerPermanentLockedLiquidityPercentage, creatorLiquidityPercentage, creatorPermanentLockedLiquidityPercentage, - sqrtStartPrice, - lockedVesting: lockedVestingParams, - migrationFeeOption: migratedPoolFeeResult.migrationFeeOption, - tokenSupply: { - preMigrationTokenSupply: totalSupply, - postMigrationTokenSupply: totalSupply, - }, + migrationQuoteThreshold: migrationQuoteThresholdInLamport, + sqrtStartPrice: sqrtStartPrice, + lockedVestingParams, + migratedPoolFeeResult, + totalSupply, creatorTradingFeePercentage, - migratedPoolFee: migratedPoolFeeResult.migratedPoolFee, - poolCreationFee: poolCreationFeeInLamports, + tokenAuthorityOption, + migrationFee, + poolCreationFeeInLamports, partnerLiquidityVestingInfo, creatorLiquidityVestingInfo, - migratedPoolBaseFeeMode: migratedPoolFeeResult.migratedPoolBaseFeeMode, - migratedPoolMarketCapFeeSchedulerParams: - migratedPoolFeeResult.migratedPoolMarketCapFeeSchedulerParams, enableFirstSwapWithMinFee, - compoundingFeeBps: migratedPoolFeeResult.compoundingFeeBps, - padding: [], curve, - tokenUpdateAuthority: tokenAuthorityOption, - migrationFee, - } - return instructionParams + }) } /** @@ -743,9 +801,9 @@ export function buildCurveWithMidPrice( baseFeeParams ) - const migrationBaseSupply = new BN(totalTokenSupply) - .mul(new BN(percentageSupplyOnMigration)) - .div(new BN(100)) + const migrationBaseSupply = new Decimal(totalTokenSupply) + .mul(new Decimal(percentageSupplyOnMigration)) + .div(new Decimal(100)) const totalSupply = convertToLamports(totalTokenSupply, tokenBaseDecimal) const migrationQuoteAmount = getMigrationQuoteAmount( @@ -758,16 +816,15 @@ export function buildCurveWithMidPrice( new Decimal(migrationFee.feePercentage) ) - const migrationPrice = migrationQuoteAmount.div( - new Decimal(migrationBaseSupply.toString()) - ) + const migrationPrice = migrationQuoteAmount.div(migrationBaseSupply) const migrationQuoteThresholdInLamport = fromDecimalToBN( migrationQuoteThreshold.mul(new Decimal(10 ** tokenQuoteDecimal)) ) - const migrationQuoteAmountInLamport = fromDecimalToBN( - migrationQuoteAmount.mul(new Decimal(10 ** tokenQuoteDecimal)) + const migrationQuoteAmountInLamport = getMigrationQuoteAmountFromThreshold( + migrationQuoteThresholdInLamport, + migrationFee.feePercentage ) const migrateSqrtPrice = getSqrtPriceFromPrice( @@ -779,7 +836,8 @@ export function buildCurveWithMidPrice( const migrationBaseAmount = getMigrationBaseToken( migrationQuoteAmountInLamport, migrateSqrtPrice, - migrationOption + migrationOption, + migratedPoolFeeResult.migratedPoolFee.collectFeeMode ) const totalVestingAmount = getTotalVestingAmount(lockedVestingParams) @@ -824,7 +882,8 @@ export function buildCurveWithMidPrice( lockedVestingParams, migrationOption, totalLeftover, - migrationFee.feePercentage + migrationFee.feePercentage, + migratedPoolFeeResult.migratedPoolFee.collectFeeMode ) if (totalDynamicSupply.gt(totalSupply)) { @@ -835,52 +894,33 @@ export function buildCurveWithMidPrice( } } - const instructionParams: ConfigParameters = { - poolFees: { - baseFee: { - ...baseFee, - }, - dynamicFee: dynamicFeeEnabled - ? getDynamicFeeParams( - baseFeeParams.baseFeeMode === BaseFeeMode.RateLimiter - ? baseFeeParams.rateLimiterParam.baseFeeBps - : baseFeeParams.feeSchedulerParam.endingFeeBps - ) - : null, - }, - activationType, + return toConfigParameters({ + baseFee, + baseFeeParams, + dynamicFeeEnabled, collectFeeMode, migrationOption, + activationType, tokenType, - tokenDecimal: tokenBaseDecimal, - migrationQuoteThreshold: migrationQuoteThresholdInLamport, + tokenBaseDecimal, partnerLiquidityPercentage, partnerPermanentLockedLiquidityPercentage, creatorLiquidityPercentage, creatorPermanentLockedLiquidityPercentage, - sqrtStartPrice, - lockedVesting: lockedVestingParams, - migrationFeeOption: migratedPoolFeeResult.migrationFeeOption, - tokenSupply: { - preMigrationTokenSupply: totalSupply, - postMigrationTokenSupply: totalSupply, - }, + migrationQuoteThreshold: migrationQuoteThresholdInLamport, + sqrtStartPrice: sqrtStartPrice, + lockedVestingParams, + migratedPoolFeeResult, + totalSupply, creatorTradingFeePercentage, - migratedPoolFee: migratedPoolFeeResult.migratedPoolFee, - poolCreationFee: poolCreationFeeInLamports, + tokenAuthorityOption, + migrationFee, + poolCreationFeeInLamports, partnerLiquidityVestingInfo, creatorLiquidityVestingInfo, - migratedPoolBaseFeeMode: migratedPoolFeeResult.migratedPoolBaseFeeMode, - migratedPoolMarketCapFeeSchedulerParams: - migratedPoolFeeResult.migratedPoolMarketCapFeeSchedulerParams, enableFirstSwapWithMinFee, - compoundingFeeBps: migratedPoolFeeResult.compoundingFeeBps, - padding: [], curve, - tokenUpdateAuthority: tokenAuthorityOption, - migrationFee, - } - return instructionParams + }) } /** @@ -1029,7 +1069,7 @@ export function buildCurveWithLiquidityWeights( let currentPrice = pMin for (let i = 0; i < 17; i++) { sqrtPrices.push(currentPrice) - currentPrice = convertDecimalToBN( + currentPrice = fromDecimalToBN( qDecimal.mul(new Decimal(currentPrice.toString())) ) } @@ -1044,15 +1084,17 @@ export function buildCurveWithLiquidityWeights( // Swap_Amount = sum(li * (1/p(i-1) - 1/pi)) // Quote_Amount = sum(li * (pi-p(i-1))) - // Quote_Amount * (1-migrationFee/100) / Base_Amount = Pmax ^ 2 + // Base_Amount = Quote_Amount * (1-migrationFee/100) * migration_base_weight - // -> Base_Amount = Quote_Amount * (1-migrationFee) / Pmax ^ 2 - // -> Swap_Amount + Base_Amount = sum(li * (1/p(i-1) - 1/pi)) + sum(li * (pi-p(i-1))) * (1-migrationFee/100) / Pmax ^ 2 + // -> Swap_Amount + Base_Amount = sum(li * (1/p(i-1) - 1/pi)) + sum(li * (pi-p(i-1))) * (1-migrationFee/100) * migration_base_weight // l0 * sum_factor = Swap_Amount + Base_Amount - // => l0 * sum_factor = sum(li * (1/p(i-1) - 1/pi)) + sum(li * (pi-p(i-1))) * (1-migrationFee/100) / Pmax ^ 2 // => l0 = (Swap_Amount + Base_Amount ) / sum_factor let sumFactor = new Decimal(0) - const pmaxWeight = new Decimal(pMax.toString()) + const migrationBaseWeight = getMigrationBaseWeight( + pMax, + migrationOption, + migratedPoolFeeResult.migratedPoolFee.collectFeeMode + ) const migrationFeeFactor = new Decimal(100) .sub(new Decimal(migrationFee.feePercentage)) .div(new Decimal(100)) @@ -1065,7 +1107,7 @@ export function buildCurveWithLiquidityWeights( const w2 = pi .sub(piMinus) // pi - piMinus .mul(migrationFeeFactor) // (1-migrationFee/100) - .div(pmaxWeight.mul(pmaxWeight)) // pmax^2 + .mul(migrationBaseWeight) const weight = k.mul(w1.add(w2)) // k x (w1 + w2) sumFactor = sumFactor.add(weight) } @@ -1077,7 +1119,7 @@ export function buildCurveWithLiquidityWeights( const curve = [] for (let i = 0; i < 16; i++) { const k = new Decimal(liquidityWeights[i]) - const liquidity = convertDecimalToBN(l1.mul(k)) + const liquidity = fromDecimalToBN(l1.mul(k)) const sqrtPrice = i < 15 ? sqrtPrices[i + 1] : pMax curve.push({ sqrtPrice, @@ -1095,7 +1137,12 @@ export function buildCurveWithLiquidityWeights( const migrationAmount = totalSwapAndMigrationAmount.sub(swapBaseAmountBuffer) - const migrationQuoteAmount = migrationAmount.mul(pMax).mul(pMax).shrn(128) + const migrationQuoteAmount = getMigrationQuoteAmountFromMigrationBase( + migrationAmount, + pMax, + migrationOption, + migratedPoolFeeResult.migratedPoolFee.collectFeeMode + ) const migrationQuoteThreshold = getMigrationQuoteThresholdFromMigrationQuoteAmount( new Decimal(migrationQuoteAmount.toString()), @@ -1113,7 +1160,8 @@ export function buildCurveWithLiquidityWeights( lockedVestingParams, migrationOption, totalLeftover, - migrationFee.feePercentage + migrationFee.feePercentage, + migratedPoolFeeResult.migratedPoolFee.collectFeeMode ) if (totalDynamicSupply.gt(totalSupply)) { @@ -1124,52 +1172,33 @@ export function buildCurveWithLiquidityWeights( } } - const instructionParams: ConfigParameters = { - poolFees: { - baseFee: { - ...baseFee, - }, - dynamicFee: dynamicFeeEnabled - ? getDynamicFeeParams( - baseFeeParams.baseFeeMode === BaseFeeMode.RateLimiter - ? baseFeeParams.rateLimiterParam.baseFeeBps - : baseFeeParams.feeSchedulerParam.endingFeeBps - ) - : null, - }, - activationType, + return toConfigParameters({ + baseFee, + baseFeeParams, + dynamicFeeEnabled, collectFeeMode, migrationOption, + activationType, tokenType, - tokenDecimal: tokenBaseDecimal, - migrationQuoteThreshold: migrationQuoteThresholdInLamport, + tokenBaseDecimal, partnerLiquidityPercentage, partnerPermanentLockedLiquidityPercentage, creatorLiquidityPercentage, creatorPermanentLockedLiquidityPercentage, + migrationQuoteThreshold: migrationQuoteThresholdInLamport, sqrtStartPrice: pMin, - lockedVesting: lockedVestingParams, - migrationFeeOption: migratedPoolFeeResult.migrationFeeOption, - tokenSupply: { - preMigrationTokenSupply: totalSupply, - postMigrationTokenSupply: totalSupply, - }, + lockedVestingParams, + migratedPoolFeeResult, + totalSupply, creatorTradingFeePercentage, - migratedPoolFee: migratedPoolFeeResult.migratedPoolFee, - poolCreationFee: poolCreationFeeInLamports, + tokenAuthorityOption, + migrationFee, + poolCreationFeeInLamports, partnerLiquidityVestingInfo, creatorLiquidityVestingInfo, - migratedPoolBaseFeeMode: migratedPoolFeeResult.migratedPoolBaseFeeMode, - migratedPoolMarketCapFeeSchedulerParams: - migratedPoolFeeResult.migratedPoolMarketCapFeeSchedulerParams, enableFirstSwapWithMinFee, - compoundingFeeBps: migratedPoolFeeResult.compoundingFeeBps, - padding: [], curve, - migrationFee, - tokenUpdateAuthority: tokenAuthorityOption, - } - return instructionParams + }) } /** @@ -1210,7 +1239,6 @@ export function buildCurveWithCustomSqrtPrices( const { tokenType, tokenBaseDecimal, - tokenQuoteDecimal, tokenAuthorityOption, totalTokenSupply, leftover, @@ -1346,9 +1374,13 @@ export function buildCurveWithCustomSqrtPrices( .sub(totalLeftover) // calculate the sum factor for liquidity distribution - // l0 * sum_factor = sum(li * (1/p(i-1) - 1/pi)) + sum(li * (pi-p(i-1))) * (1-migrationFee/100) / Pmax ^ 2 + // l0 * sum_factor = sum(li * (1/p(i-1) - 1/pi)) + sum(li * (pi-p(i-1))) * (1-migrationFee/100) * migration_base_weight let sumFactor = new Decimal(0) - const pmaxWeight = new Decimal(pMax.toString()) + const migrationBaseWeight = getMigrationBaseWeight( + pMax, + migrationOption, + migratedPoolFeeResult.migratedPoolFee.collectFeeMode + ) const migrationFeeFactor = new Decimal(100) .sub(new Decimal(migrationFee.feePercentage)) .div(new Decimal(100)) @@ -1363,11 +1395,11 @@ export function buildCurveWithCustomSqrtPrices( // w1 = (pi - piMinus) / (pi * piMinus) represents the base token contribution const w1 = pi.sub(piMinus).div(pi.mul(piMinus)) - // w2 = (pi - piMinus) * (1 - migrationFee) / pMax^2 represents the quote token contribution + // w2 = (pi - piMinus) * (1 - migrationFee) * migration_base_weight represents the migration base contribution const w2 = pi .sub(piMinus) .mul(migrationFeeFactor) - .div(pmaxWeight.mul(pmaxWeight)) + .mul(migrationBaseWeight) const weight = k.mul(w1.add(w2)) sumFactor = sumFactor.add(weight) @@ -1382,7 +1414,7 @@ export function buildCurveWithCustomSqrtPrices( const curve = [] for (let i = 0; i < numSegments; i++) { const k = new Decimal(liquidityWeights[i]) - const liquidity = convertDecimalToBN(l1.mul(k)) + const liquidity = fromDecimalToBN(l1.mul(k)) const sqrtPrice = sqrtPrices[i + 1] curve.push({ sqrtPrice, @@ -1401,7 +1433,12 @@ export function buildCurveWithCustomSqrtPrices( const migrationAmount = totalSwapAndMigrationAmount.sub(swapBaseAmountBuffer) - const migrationQuoteAmount = migrationAmount.mul(pMax).mul(pMax).shrn(128) + const migrationQuoteAmount = getMigrationQuoteAmountFromMigrationBase( + migrationAmount, + pMax, + migrationOption, + migratedPoolFeeResult.migratedPoolFee.collectFeeMode + ) const migrationQuoteThreshold = getMigrationQuoteThresholdFromMigrationQuoteAmount( new Decimal(migrationQuoteAmount.toString()), @@ -1419,7 +1456,8 @@ export function buildCurveWithCustomSqrtPrices( lockedVestingParams, migrationOption, totalLeftover, - migrationFee.feePercentage + migrationFee.feePercentage, + migratedPoolFeeResult.migratedPoolFee.collectFeeMode ) if (totalDynamicSupply.gt(totalSupply)) { @@ -1430,50 +1468,31 @@ export function buildCurveWithCustomSqrtPrices( } } - const instructionParams: ConfigParameters = { - poolFees: { - baseFee: { - ...baseFee, - }, - dynamicFee: dynamicFeeEnabled - ? getDynamicFeeParams( - baseFeeParams.baseFeeMode === BaseFeeMode.RateLimiter - ? baseFeeParams.rateLimiterParam.baseFeeBps - : baseFeeParams.feeSchedulerParam.endingFeeBps - ) - : null, - }, - activationType, + return toConfigParameters({ + baseFee, + baseFeeParams, + dynamicFeeEnabled, collectFeeMode, migrationOption, + activationType, tokenType, - tokenDecimal: tokenBaseDecimal, - migrationQuoteThreshold: migrationQuoteThresholdInLamport, + tokenBaseDecimal, partnerLiquidityPercentage, partnerPermanentLockedLiquidityPercentage, creatorLiquidityPercentage, creatorPermanentLockedLiquidityPercentage, + migrationQuoteThreshold: migrationQuoteThresholdInLamport, sqrtStartPrice: pMin, - lockedVesting: lockedVestingParams, - migrationFeeOption: migratedPoolFeeResult.migrationFeeOption, - tokenSupply: { - preMigrationTokenSupply: totalSupply, - postMigrationTokenSupply: totalSupply, - }, + lockedVestingParams, + migratedPoolFeeResult, + totalSupply, creatorTradingFeePercentage, - migratedPoolFee: migratedPoolFeeResult.migratedPoolFee, - poolCreationFee: poolCreationFeeInLamports, + tokenAuthorityOption, + migrationFee, + poolCreationFeeInLamports, partnerLiquidityVestingInfo, creatorLiquidityVestingInfo, - migratedPoolBaseFeeMode: migratedPoolFeeResult.migratedPoolBaseFeeMode, - migratedPoolMarketCapFeeSchedulerParams: - migratedPoolFeeResult.migratedPoolMarketCapFeeSchedulerParams, enableFirstSwapWithMinFee, - compoundingFeeBps: migratedPoolFeeResult.compoundingFeeBps, - padding: [], curve, - migrationFee, - tokenUpdateAuthority: tokenAuthorityOption, - } - return instructionParams + }) } diff --git a/packages/dynamic-bonding-curve/src/helpers/common.ts b/packages/dynamic-bonding-curve/src/helpers/common.ts index 0d2b4446..3393c040 100644 --- a/packages/dynamic-bonding-curve/src/helpers/common.ts +++ b/packages/dynamic-bonding-curve/src/helpers/common.ts @@ -1,25 +1,27 @@ import { + ActivationType, BaseFee, - DynamicFeeParameters, BaseFeeMode, + BaseFeeParams, + DammV2BaseFeeMode, + DynamicFeeParameters, + LiquidityVestingInfoParameters, + MigratedCollectFeeMode, + MigratedPoolFeeConfig, + MigratedPoolFeeResult, + MigratedPoolMarketCapFeeSchedulerParameters, + MigrationFeeOption, MigrationOption, Rounding, TokenDecimal, type LiquidityDistributionParameters, type LockedVestingParameters, - ActivationType, type PoolConfig, - MigratedPoolFeeConfig, - MigrationFeeOption, - LiquidityVestingInfoParameters, - DammV2BaseFeeMode, - MigratedPoolMarketCapFeeSchedulerParameters, - BaseFeeParams, - MigratedPoolFeeResult, } from '../types' import { BIN_STEP_BPS_DEFAULT, BIN_STEP_BPS_U128_DEFAULT, + DAMM_V2_COMPOUNDING_DEAD_LIQUIDITY, DEFAULT_MIGRATED_POOL_FEE_PARAMS, DEFAULT_MIGRATED_POOL_MARKET_CAP_FEE_SCHEDULER_PARAMS, DYNAMIC_FEE_DECAY_PERIOD_DEFAULT, @@ -38,11 +40,23 @@ import { MIN_FEE_NUMERATOR, MIN_SQRT_PRICE, ONE_Q64, + PROTOCOL_LIQUIDITY_MIGRATION_FEE_BPS, SWAP_BUFFER_PERCENTAGE, U128_MAX, + U64_MAX, } from '../constants' import BN from 'bn.js' import Decimal from 'decimal.js' +import { Commitment, Connection, PublicKey } from '@solana/web3.js' +import type { DynamicBondingCurve } from '../idl/dynamic-bonding-curve/idl' +import { Program } from '@coral-xyz/anchor' +import { + bpsToFeeNumerator, + convertToLamports, + feeNumeratorToBps, +} from './utils' +import { getFeeNumeratorOnExponentialFeeScheduler } from '../math/poolFees/feeScheduler' +import { getTokenDecimals } from './token' import { getDeltaAmountBaseUnsigned, getDeltaAmountQuoteUnsigned, @@ -50,12 +64,11 @@ import { getInitialLiquidityFromDeltaQuote, getNextSqrtPriceFromInput, } from '../math/curve' -import { Commitment, Connection, PublicKey } from '@solana/web3.js' -import type { DynamicBondingCurve } from '../idl/dynamic-bonding-curve/idl' -import { Program } from '@coral-xyz/anchor' -import { bpsToFeeNumerator, convertToLamports, fromDecimalToBN } from './utils' -import { getTokenDecimals } from './token' -import { mulDiv } from '../math' +import { mulDiv, sqrt } from '../math/utilsMath' +import { + calculateTransferFeeExcludedAmount, + type EpochTransferFee, +} from '../math/transferFee' /** * Get the first key @@ -155,47 +168,43 @@ export async function getAccountCreationTimestamps( } /** - * Get the total token supply - * @param swapBaseAmount - The swap base amount - * @param migrationBaseThreshold - The migration base threshold - * @param lockedVestingParams - The locked vesting parameters - * @returns The total token supply + * Get the current point based on activation type + * @param connection - The Solana connection instance + * @param activationType - The activation type (Slot or Time) + * @returns The current point as a BN */ -export function getTotalTokenSupply( - swapBaseAmount: BN, - migrationBaseThreshold: BN, - lockedVestingParams: { - amountPerPeriod: BN - numberOfPeriod: BN - cliffUnlockAmount: BN - } -): BN { - try { - // calculate total circulating amount - const totalCirculatingAmount = swapBaseAmount.add( - migrationBaseThreshold - ) +export async function getCurrentPoint( + connection: Connection, + activationType: ActivationType +): Promise { + const currentSlot = await connection.getSlot() - // calculate total locked vesting amount - const totalLockedVestingAmount = - lockedVestingParams.cliffUnlockAmount.add( - lockedVestingParams.amountPerPeriod.mul( - lockedVestingParams.numberOfPeriod - ) - ) + if (activationType === ActivationType.Slot) { + return new BN(currentSlot) + } - // calculate total amount - const totalAmount = totalCirculatingAmount.add(totalLockedVestingAmount) + const currentTime = await connection.getBlockTime(currentSlot) + if (currentTime === null) { + throw new Error(`Block time is unavailable for slot ${currentSlot}`) + } + return new BN(currentTime) +} - // check for overflow - if (totalAmount.isNeg() || totalAmount.bitLength() > 64) { - throw new Error('Math overflow') - } +/** + * Prepare the swap amount param + * @param amount - The amount to swap + * @param mintAddress - The mint address + * @param connection - The Solana connection instance + * @returns The amount in lamports + */ +export async function prepareSwapAmountParam( + amount: number, + mintAddress: PublicKey, + connection: Connection +): Promise { + const mintTokenDecimals = await getTokenDecimals(connection, mintAddress) - return totalAmount - } catch (error) { - throw new Error(`Math overflow: ${error}`) - } + return convertToLamports(amount, mintTokenDecimals) } /** @@ -326,128 +335,66 @@ export function getBaseTokenForSwap( } /** - * Get migrationQuoteAmount from migrationQuoteThreshold and migrationFeePercent - * @param migrationQuoteThreshold - The migration quote threshold - * @param migrationFeePercent - The migration fee percent - * @returns migration quote amount to deposit to pool - */ -export const getMigrationQuoteAmountFromMigrationQuoteThreshold = ( - migrationQuoteThreshold: Decimal, - migrationFeePercent: number -): Decimal => { - const migrationQuoteAmount = migrationQuoteThreshold - .mul(new Decimal(100).sub(new Decimal(migrationFeePercent))) - .div(new Decimal(100)) - return migrationQuoteAmount -} - -/** - * Get migrationQuoteThreshold from migrationQuoteAmount and migrationFeePercent - * @param migrationQuoteAmount - The migration quote amount - * @param migrationFeePercent - The migration fee percent - * @returns migration quote threshold on bonding curve - */ -export const getMigrationQuoteThresholdFromMigrationQuoteAmount = ( - migrationQuoteAmount: Decimal, - migrationFeePercent: Decimal -): Decimal => { - const migrationQuoteThreshold = migrationQuoteAmount - .mul(new Decimal(100)) - .div(new Decimal(100).sub(new Decimal(migrationFeePercent))) - return migrationQuoteThreshold -} - -/** - * Calculates the protocol migration fee for both base and quote tokens. - * - * @param depositBaseAmount - Amount of base token to deposit in pool (BN) - * @param depositQuoteAmount - Amount of quote token to deposit in pool (BN) - * @param migrationSqrtPrice - Migration sqrt price (BN) - * @param migrationFeeBps - Migration fee in basis points (number) - * @param migrationOption - Migration option (MigrationOption, enum) - * @returns [baseFeeAmount: BN, quoteFeeAmount: BN] + * Computes the sqrtPriceStepBps needed so that the fee schedule is fully + * exhausted when spot price reaches a given multiple of the initial price. + * @param priceMultiple - Target spot-price multiple (e.g. 1000 for 1000x) + * @param numberOfPeriod - Number of fee reduction periods + * @returns The sqrtPriceStepBps value to use on-chain */ -export function getProtocolMigrationFee( - depositBaseAmount: BN, - depositQuoteAmount: BN, - migrationSqrtPrice: BN, - migrationFeeBps: number, - migrationOption: MigrationOption -): [BN, BN] { - // quote fee amount = (depositQuoteAmount * migrationFeeBps) / MAX_BASIS_POINT - const quoteFeeAmount = mulDiv( - depositQuoteAmount, - new BN(migrationFeeBps), - new BN(MAX_BASIS_POINT), - Rounding.Down +export function computeSqrtPriceStepBps( + priceMultiple: number, + numberOfPeriod: number +): number { + if (priceMultiple <= 1) { + throw new Error('priceMultiple must be greater than 1') + } + if (numberOfPeriod <= 0) { + throw new Error('numberOfPeriod must be greater than 0') + } + const sqrtPriceStepBps = Math.floor( + ((Math.sqrt(priceMultiple) - 1) * MAX_BASIS_POINT) / numberOfPeriod ) - - if (migrationOption === MigrationOption.MET_DAMM) { - // DAMM v1 migration: fee as same ratio for base - const baseFeeAmount = mulDiv( - depositBaseAmount, - new BN(migrationFeeBps), - new BN(MAX_BASIS_POINT), - Rounding.Down - ) - return [baseFeeAmount, quoteFeeAmount] - } else if (migrationOption === MigrationOption.MET_DAMM_V2) { - // DAMM v2 migration - const feeLiquidity = getInitialLiquidityFromDeltaQuote( - quoteFeeAmount, - MIN_SQRT_PRICE, - migrationSqrtPrice - ) - const baseFeeAmount = getDeltaAmountBaseUnsigned( - migrationSqrtPrice, - MAX_SQRT_PRICE, - feeLiquidity, - Rounding.Down + if (sqrtPriceStepBps <= 0) { + throw new Error( + 'Computed sqrtPriceStepBps is 0 — increase priceMultiple or decrease numberOfPeriod' ) - return [baseFeeAmount, quoteFeeAmount] - } else { - throw new Error('Invalid migration option') } + return sqrtPriceStepBps } /** - * Get the base token for migration - * @param migrationQuoteAmount - The migration quote amount to deposit to pool - * @param sqrtMigrationPrice - The migration sqrt price - * @param migrationOption - The migration option - * @returns The base token + * Get the quote token amount from sqrt price + * @param nextSqrtPrice - The next sqrt price + * @param config - The pool configuration + * @returns The total quote token amount */ -export const getMigrationBaseToken = ( - migrationQuoteAmount: BN, - sqrtMigrationPrice: BN, - migrationOption: MigrationOption -): BN => { - if (migrationOption == MigrationOption.MET_DAMM) { - const price = sqrtMigrationPrice.mul(sqrtMigrationPrice) - const quote = migrationQuoteAmount.shln(128) - const { div: baseDiv, mod } = quote.divmod(price) - let div = baseDiv - if (!mod.isZero()) { - div = div.add(new BN(1)) +export function getQuoteReserveFromNextSqrtPrice( + nextSqrtPrice: BN, + config: PoolConfig +): BN { + let totalAmount = new BN(0) + + for (let i = 0; i < config.curve.length; i++) { + const lowerSqrtPrice = + i === 0 ? config.sqrtStartPrice : config.curve[i - 1].sqrtPrice + + if (nextSqrtPrice.gt(lowerSqrtPrice)) { + const upperSqrtPrice = nextSqrtPrice.lt(config.curve[i].sqrtPrice) + ? nextSqrtPrice + : config.curve[i].sqrtPrice + + const maxAmountIn = getDeltaAmountQuoteUnsigned( + lowerSqrtPrice, + upperSqrtPrice, + config.curve[i].liquidity, + Rounding.Up + ) + + totalAmount = totalAmount.add(maxAmountIn) } - return div - } else if (migrationOption == MigrationOption.MET_DAMM_V2) { - const liquidity = getInitialLiquidityFromDeltaQuote( - migrationQuoteAmount, - MIN_SQRT_PRICE, - sqrtMigrationPrice - ) - // calculate base threshold - const baseAmount = getDeltaAmountBaseUnsigned( - sqrtMigrationPrice, - MAX_SQRT_PRICE, - liquidity, - Rounding.Up - ) - return baseAmount - } else { - throw Error('Invalid migration option') } + + return totalAmount } /** @@ -465,419 +412,365 @@ export const getTotalVestingAmount = ( } /** - * Get the liquidity - * @param baseAmount - The base amount - * @param quoteAmount - The quote amount - * @param minSqrtPrice - The min sqrt price - * @param maxSqrtPrice - The max sqrt price - * @returns The liquidity - */ -export const getLiquidity = ( - baseAmount: BN, - quoteAmount: BN, - minSqrtPrice: BN, - maxSqrtPrice: BN -): BN => { - const liquidityFromBase = getInitialLiquidityFromDeltaBase( - baseAmount, - maxSqrtPrice, - minSqrtPrice - ) - const liquidityFromQuote = getInitialLiquidityFromDeltaQuote( - quoteAmount, - minSqrtPrice, - maxSqrtPrice - ) - return BN.min(liquidityFromBase, liquidityFromQuote) -} - -/** - * Get the first curve - * @param migrationSqrPrice - The migration sqrt price - * @param migrationAmount - The migration amount - * @param swapAmount - The swap amount - * @param migrationQuoteThreshold - The migration quote threshold - * @param migrationFeePercent - The migration fee percent - * @returns The first curve + * Calculate the locked vesting parameters + * @param totalLockedVestingAmount - The total vesting amount + * @param numberOfVestingPeriod - The number of periods + * @param cliffUnlockAmount - The amount to unlock at cliff + * @param totalVestingDuration - The total duration of vesting + * @param cliffDurationFromMigrationTime - The cliff duration from migration time + * @param tokenBaseDecimal - The decimal of the base token + * @returns The locked vesting parameters + * total_locked_vesting_amount = cliff_unlock_amount + (amount_per_period * number_of_period) */ -export const getFirstCurve = ( - migrationSqrtPrice: BN, - migrationBaseAmount: BN, - swapAmount: BN, - migrationQuoteThreshold: BN, - migrationFeePercent: number -) => { - // Swap_amount = L * (1/Pmin - 1/Pmax) = L * (Pmax - Pmin) / (Pmax * Pmin) (1) - // Quote_amount = L * (Pmax - Pmin) (2) - // (Quote_amount * (1-migrationFeePercent/100) / Migration_amount = Pmax ^ 2 (3) - const migrationSqrPriceDecimal = new Decimal(migrationSqrtPrice.toString()) - const migrationBaseAmountDecimal = new Decimal( - migrationBaseAmount.toString() - ) - const swapAmountDecimal = new Decimal(swapAmount.toString()) - const migrationFeePercentDecimal = new Decimal( - migrationFeePercent.toString() - ) - // From (1) and (2) => Quote_amount / Swap_amount = (Pmax * Pmin) (4) - // From (3) and (4) => Swap_amount * (1-migrationFeePercent/100) / Migration_amount = Pmax / Pmin - // => Pmin = Pmax * Migration_amount / (Swap_amount * (1-migrationFeePercent/100)) - const denominator = swapAmountDecimal - .mul(new Decimal(100).sub(migrationFeePercentDecimal)) - .div(new Decimal(100)) +export function getLockedVestingParams( + totalLockedVestingAmount: number, + numberOfVestingPeriod: number, + cliffUnlockAmount: number, + totalVestingDuration: number, + cliffDurationFromMigrationTime: number, + tokenBaseDecimal: TokenDecimal +): LockedVestingParameters { + if (totalLockedVestingAmount == 0) { + return { + amountPerPeriod: new BN(0), + cliffDurationFromMigrationTime: new BN(0), + frequency: new BN(0), + numberOfPeriod: new BN(0), + cliffUnlockAmount: new BN(0), + } + } - const sqrtStartPriceDecimal = migrationSqrPriceDecimal - .mul(migrationBaseAmountDecimal) - .div(denominator) + if (totalLockedVestingAmount == cliffUnlockAmount) { + return { + amountPerPeriod: convertToLamports(1, tokenBaseDecimal), + cliffDurationFromMigrationTime: new BN( + cliffDurationFromMigrationTime + ), + frequency: new BN(1), + numberOfPeriod: new BN(1), + cliffUnlockAmount: convertToLamports( + totalLockedVestingAmount - 1, + tokenBaseDecimal + ), + } + } - const sqrtStartPrice = new BN(sqrtStartPriceDecimal.floor().toFixed()) + if (numberOfVestingPeriod <= 0 || totalVestingDuration <= 0) { + throw new Error( + 'numberOfPeriod and totalVestingDuration must both be greater than zero' + ) + } - const liquidity = getLiquidity( - swapAmount, - migrationQuoteThreshold, - sqrtStartPrice, - migrationSqrtPrice - ) - return { - sqrtStartPrice, - curve: [ - { - sqrtPrice: migrationSqrtPrice, - liquidity, - }, - ], + if (cliffUnlockAmount > totalLockedVestingAmount) { + throw new Error( + 'Cliff unlock amount cannot be greater than total locked vesting amount' + ) } -} -/** - * Get the total supply from curve - * @param migrationQuoteThreshold - The migration quote threshold - * @param sqrtStartPrice - The start sqrt price - * @param curve - The curve - * @param lockedVesting - The locked vesting - * @param migrationOption - The migration option - * @param leftover - The leftover - * @param migrationFeePercent - The migration fee percent - * @returns The total supply - */ -export const getTotalSupplyFromCurve = ( - migrationQuoteThreshold: BN, - sqrtStartPrice: BN, - curve: Array, - lockedVesting: LockedVestingParameters, - migrationOption: MigrationOption, - leftover: BN, - migrationFeePercent: number -): BN => { - const sqrtMigrationPrice = getMigrationThresholdPrice( - migrationQuoteThreshold, - sqrtStartPrice, - curve - ) - const swapBaseAmount = getBaseTokenForSwap( - sqrtStartPrice, - sqrtMigrationPrice, - curve - ) - const swapBaseAmountBuffer = getSwapAmountWithBuffer( - swapBaseAmount, - sqrtStartPrice, - curve - ) + // amount_per_period = (total_locked_vesting_amount - cliff_unlock_amount) / number_of_period + const amountPerPeriod = + (totalLockedVestingAmount - cliffUnlockAmount) / numberOfVestingPeriod - const migrationQuoteAmount = - getMigrationQuoteAmountFromMigrationQuoteThreshold( - new Decimal(migrationQuoteThreshold.toString()), - migrationFeePercent - ) - const migrationBaseAmount = getMigrationBaseToken( - fromDecimalToBN(migrationQuoteAmount), - sqrtMigrationPrice, - migrationOption + // round amountPerPeriod down to ensure we don't exceed total amount + const roundedAmountPerPeriod = Math.floor(amountPerPeriod) + + // calculate the remainder from rounding down + const totalPeriodicAmount = roundedAmountPerPeriod * numberOfVestingPeriod + const remainder = + totalLockedVestingAmount - (cliffUnlockAmount + totalPeriodicAmount) + + // add the remainder to cliffUnlockAmount to maintain total amount + const adjustedCliffUnlockAmount = cliffUnlockAmount + remainder + + const periodFrequency = new BN( + Math.floor(totalVestingDuration / numberOfVestingPeriod) ) - const totalVestingAmount = getTotalVestingAmount(lockedVesting) - const minimumBaseSupplyWithBuffer = swapBaseAmountBuffer - .add(migrationBaseAmount) - .add(totalVestingAmount) - .add(leftover) - return minimumBaseSupplyWithBuffer -} -/** - * Get the migration threshold price - * @param migrationThreshold - The migration threshold - * @param sqrtStartPrice - The start sqrt price - * @param curve - The curve - * @returns The migration threshold price - */ -export const getMigrationThresholdPrice = ( - migrationThreshold: BN, - sqrtStartPrice: BN, - curve: Array -): BN => { - let nextSqrtPrice = sqrtStartPrice + return { + amountPerPeriod: convertToLamports( + roundedAmountPerPeriod, + tokenBaseDecimal + ), + cliffDurationFromMigrationTime: new BN(cliffDurationFromMigrationTime), + frequency: periodFrequency, + numberOfPeriod: new BN(numberOfVestingPeriod), + cliffUnlockAmount: convertToLamports( + adjustedCliffUnlockAmount, + tokenBaseDecimal + ), + } +} - if (curve.length === 0) { - throw Error('Curve is empty') +export const getLiquidityVestingInfoParams = ( + vestingPercentage: number, + bpsPerPeriod: number, + numberOfPeriods: number, + cliffDurationFromMigrationTime: number, + totalDuration: number +): LiquidityVestingInfoParameters => { + // validate vestingPercentage (0-100, u8) + if (vestingPercentage < 0 || vestingPercentage > 100) { + throw new Error('vestingPercentage must be between 0 and 100') } - const totalAmount = getDeltaAmountQuoteUnsigned( - nextSqrtPrice, - curve[0].sqrtPrice, - curve[0].liquidity, - Rounding.Up - ) - if (totalAmount.gt(migrationThreshold)) { - nextSqrtPrice = getNextSqrtPriceFromInput( - nextSqrtPrice, - curve[0].liquidity, - migrationThreshold, - false - ) - } else { - let amountLeft = migrationThreshold.sub(totalAmount) - nextSqrtPrice = curve[0].sqrtPrice - for (let i = 1; i < curve.length; i++) { - const maxAmount = getDeltaAmountQuoteUnsigned( - nextSqrtPrice, - curve[i].sqrtPrice, - curve[i].liquidity, - Rounding.Up + // if vestingPercentage is 0, all other params should be 0 (zero vesting case) + if (vestingPercentage === 0) { + if ( + bpsPerPeriod !== 0 || + numberOfPeriods !== 0 || + cliffDurationFromMigrationTime !== 0 || + totalDuration !== 0 + ) { + throw new Error( + 'If vestingPercentage is 0, all other parameters must be 0' ) - if (maxAmount.gt(amountLeft)) { - nextSqrtPrice = getNextSqrtPriceFromInput( - nextSqrtPrice, - curve[i].liquidity, - amountLeft, - false - ) - amountLeft = new BN(0) - break - } else { - amountLeft = amountLeft.sub(maxAmount) - nextSqrtPrice = curve[i].sqrtPrice - } } - if (!amountLeft.isZero()) { - const migrationThresholdStr = migrationThreshold.toString() - const amountLeftStr = amountLeft.toString() - throw Error( - `Not enough liquidity, migrationThreshold: ${migrationThresholdStr} amountLeft: ${amountLeftStr}` - ) + return { + vestingPercentage: 0, + bpsPerPeriod: 0, + numberOfPeriods: 0, + cliffDurationFromMigrationTime: 0, + frequency: 0, } } - return nextSqrtPrice -} -/** - * Calculate the quote amount allocated to each curve segment - * Formula: Δb = L * (√P_upper - √P_lower) for each segment - * @param migrationQuoteThreshold - The total migration quote threshold - * @param sqrtStartPrice - The start sqrt price - * @param curve - The curve segments with sqrtPrice and liquidity - * @returns Array of quote amounts for each segment and the final sqrt price reached - */ -export const getCurveBreakdown = ( - migrationQuoteThreshold: BN, - sqrtStartPrice: BN, - curve: Array -): { - segmentAmounts: BN[] - finalSqrtPrice: BN - totalAmount: BN -} => { - if (curve.length === 0) { - throw Error('Curve is empty') + if (bpsPerPeriod < 0 || bpsPerPeriod > MAX_BASIS_POINT) { + throw new Error(`bpsPerPeriod must be between 0 and ${MAX_BASIS_POINT}`) } - const segmentAmounts: BN[] = [] - let totalAllocated = new BN(0) - let currentSqrtPrice = sqrtStartPrice - let finalSqrtPrice = sqrtStartPrice + if (numberOfPeriods <= 0) { + throw new Error( + 'numberOfPeriods must be greater than zero when vestingPercentage > 0' + ) + } - for (let i = 0; i < curve.length; i++) { - const lowerSqrtPrice = currentSqrtPrice - const upperSqrtPrice = curve[i].sqrtPrice - const liquidity = curve[i].liquidity + if (cliffDurationFromMigrationTime < 0) { + throw new Error('cliffDurationFromMigrationTime must be >= 0') + } - // calculate max quote amount available in this segment - // formula: Δb = L * (√P_upper - √P_lower) - const maxSegmentAmount = getDeltaAmountQuoteUnsigned( - lowerSqrtPrice, - upperSqrtPrice, - liquidity, - Rounding.Up - ) + if (totalDuration <= 0) { + throw new Error('totalDuration must be greater than zero') + } - if (maxSegmentAmount.gte(migrationQuoteThreshold)) { - // this segment contains the migration point - segmentAmounts.push(migrationQuoteThreshold) - totalAllocated = totalAllocated.add(migrationQuoteThreshold) + const frequency = Math.floor(totalDuration / numberOfPeriods) - // calculate the exact sqrt price where migration threshold is reached - finalSqrtPrice = getNextSqrtPriceFromInput( - lowerSqrtPrice, - liquidity, - migrationQuoteThreshold, - false - ) + if (frequency <= 0) { + throw new Error( + 'frequency must be greater than zero (totalDuration / numberOfPeriods must be > 0)' + ) + } - // fill remaining segments with zero - for (let j = i + 1; j < curve.length; j++) { - segmentAmounts.push(new BN(0)) - } - break - } else { - // this segment is fully consumed - segmentAmounts.push(maxSegmentAmount) - totalAllocated = totalAllocated.add(maxSegmentAmount) - currentSqrtPrice = upperSqrtPrice - finalSqrtPrice = upperSqrtPrice + const totalBps = bpsPerPeriod * numberOfPeriods + if (totalBps > MAX_BASIS_POINT) { + throw new Error( + `Total BPS (bpsPerPeriod * numberOfPeriods = ${totalBps}) must not exceed ${MAX_BASIS_POINT}` + ) + } - // check if we've processed all segments but haven't reached threshold - if ( - i === curve.length - 1 && - totalAllocated.lt(migrationQuoteThreshold) - ) { - const shortfall = migrationQuoteThreshold.sub(totalAllocated) - throw Error( - `Not enough liquidity in curve. Total allocated: ${totalAllocated.toString()}, Required: ${migrationQuoteThreshold.toString()}, Shortfall: ${shortfall.toString()}` - ) - } - } + const totalVestingDuration = + cliffDurationFromMigrationTime + numberOfPeriods * frequency + if (totalVestingDuration > MAX_LOCK_DURATION_IN_SECONDS) { + throw new Error( + `Total vesting duration (${totalVestingDuration}s) must not exceed ${MAX_LOCK_DURATION_IN_SECONDS}s (2 years)` + ) + } + + if (cliffDurationFromMigrationTime === 0 && numberOfPeriods === 0) { + throw new Error( + 'If cliffDurationFromMigrationTime is 0, numberOfPeriods must be > 0' + ) } return { - segmentAmounts, - finalSqrtPrice, - totalAmount: totalAllocated, + vestingPercentage, + bpsPerPeriod, + numberOfPeriods, + cliffDurationFromMigrationTime, + frequency, } } /** - * Get the swap amount with buffer - * @param swapBaseAmount - The swap base amount - * @param sqrtStartPrice - The start sqrt price - * @param curve - The curve - * @returns The swap amount with buffer + * Calculate the locked liquidity BPS for a single vesting info at a given time. + * @param vestingInfo - The liquidity vesting info parameters + * @param nSeconds - Number of seconds after migration + * @returns The locked liquidity in BPS (basis points) */ -export const getSwapAmountWithBuffer = ( - swapBaseAmount: BN, - sqrtStartPrice: BN, - curve: Array -): BN => { - const swapAmountBuffer = swapBaseAmount.add( - swapBaseAmount.mul(new BN(SWAP_BUFFER_PERCENTAGE)).div(new BN(100)) - ) - const maxBaseAmountOnCurve = getBaseTokenForSwap( - sqrtStartPrice, - MAX_SQRT_PRICE, - curve +export function getVestingLockedLiquidityBpsAtNSeconds( + vestingInfo: LiquidityVestingInfoParameters | undefined, + nSeconds: number +): number { + // If no vesting info or vesting percentage is 0, return 0 + if (!vestingInfo || vestingInfo.vestingPercentage === 0) { + return 0 + } + + const totalLiquidity = U128_MAX + + // total_vested_liquidity = floor(total_liquidity * vesting_percentage / 100) + const totalVestedLiquidity = totalLiquidity + .mul(new BN(vestingInfo.vestingPercentage)) + .div(new BN(100)) + + const bpsPerPeriod = vestingInfo.bpsPerPeriod + const numberOfPeriods = vestingInfo.numberOfPeriods + const frequency = vestingInfo.frequency + const cliffDuration = vestingInfo.cliffDurationFromMigrationTime + + // calculate total BPS that will be unlocked over all periods + const totalBpsAfterCliff = bpsPerPeriod * numberOfPeriods + + // total_vesting_liquidity_after_cliff = floor(total_vested_liquidity * total_bps_after_cliff / MAX_BASIS_POINT) + const totalVestingLiquidityAfterCliff = totalVestedLiquidity + .mul(new BN(totalBpsAfterCliff)) + .div(new BN(MAX_BASIS_POINT)) + + // liquidity_per_period = floor(total_vesting_liquidity_after_cliff / number_of_periods) + let liquidityPerPeriod = new BN(0) + let adjustedFrequency = frequency + let adjustedNumberOfPeriods = numberOfPeriods + let adjustedCliffDuration = cliffDuration + + if (numberOfPeriods > 0) { + liquidityPerPeriod = totalVestingLiquidityAfterCliff.div( + new BN(numberOfPeriods) + ) + } + + // If liquidity_per_period == 0 (due to precision loss), make it cliff-only lock + if (liquidityPerPeriod.isZero()) { + adjustedNumberOfPeriods = 0 + adjustedFrequency = 0 + adjustedCliffDuration = Math.max(cliffDuration, 1) + } + + // cliff_unlock_liquidity = total_vested_liquidity - (liquidity_per_period * number_of_periods) + const cliffUnlockLiquidity = totalVestedLiquidity.sub( + liquidityPerPeriod.mul(new BN(adjustedNumberOfPeriods)) ) - return BN.min(swapAmountBuffer, maxBaseAmountOnCurve) -} -/** - * Get the percentage of supply that should be allocated to initial liquidity - * @param initialMarketCap - The initial market cap - * @param migrationMarketCap - The migration market cap - * @param lockedVesting - The locked vesting - * @param totalLeftover - The leftover - * @param totalTokenSupply - The total token supply - * @returns The percentage of supply for initial liquidity - */ -export const getPercentageSupplyOnMigration = ( - initialMarketCap: Decimal, - migrationMarketCap: Decimal, - lockedVesting: LockedVestingParameters, - totalLeftover: BN, - totalTokenSupply: BN -): number => { - // formula: x = sqrt(initialMC / migrationMC) * (100 - lockedVesting - leftover) / (1 + sqrt(initialMC / migrationMC)) + // calculate unlocked liquidity at nSeconds using vesting parameters + // cliff_point = current_timestamp (0) + cliff_duration + const cliffPoint = new BN(adjustedCliffDuration) + const currentPoint = new BN(nSeconds) - // sqrtRatio = sqrt(initial_MC / migration_MC) - const marketCapRatio = initialMarketCap.div(migrationMarketCap) - const sqrtRatio = Decimal.sqrt(marketCapRatio) + let unlockedLiquidity = new BN(0) - // locked vesting percentage - const totalVestingAmount = getTotalVestingAmount(lockedVesting) - const vestingPercentage = new Decimal(totalVestingAmount.toString()) - .mul(new Decimal(100)) - .div(new Decimal(totalTokenSupply.toString())) + if (currentPoint.gte(cliffPoint)) { + // past cliff - add cliff unlock amount + unlockedLiquidity = cliffUnlockLiquidity - // leftover percentage - const leftoverPercentage = new Decimal(totalLeftover.toString()) - .mul(new Decimal(100)) - .div(new Decimal(totalTokenSupply.toString())) + // calculate periods elapsed after cliff + if (adjustedFrequency > 0 && adjustedNumberOfPeriods > 0) { + const timeAfterCliff = currentPoint.sub(cliffPoint) + const periodsElapsed = timeAfterCliff + .div(new BN(adjustedFrequency)) + .toNumber() + const actualPeriodsElapsed = Math.min( + periodsElapsed, + adjustedNumberOfPeriods + ) + unlockedLiquidity = unlockedLiquidity.add( + liquidityPerPeriod.mul(new BN(actualPeriodsElapsed)) + ) + } + } - // (100 * sqrtRatio - (vestingPercentage + leftoverPercentage) * sqrtRatio) / (1 + sqrtRatio) - const numerator = new Decimal(100) - .mul(sqrtRatio) - .sub(vestingPercentage.add(leftoverPercentage).mul(sqrtRatio)) - const denominator = new Decimal(1).add(sqrtRatio) - return numerator.div(denominator).toNumber() + // locked_liquidity = total_vested_liquidity - unlocked_liquidity + const lockedLiquidity = totalVestedLiquidity.sub(unlockedLiquidity) + + // liquidity_locked_bps = floor(locked_liquidity * MAX_BASIS_POINT / total_liquidity) + const liquidityLockedBps = lockedLiquidity + .mul(new BN(MAX_BASIS_POINT)) + .div(totalLiquidity) + + return liquidityLockedBps.toNumber() } /** - * Calculate the adjusted percentageSupplyOnMigration that accounts for migrationFee - * - * Formula: - * - D = desiredMarketCap - * - M = migrationMarketCap - * - f = migrationFee - * - V = vesting percentage - * - L = leftover percentage - * - * requiredRatio = sqrt(D / M) - * percentageSupplyOnMigration = (requiredRatio * (1 - f) * (100 - V - L)) / (1 + requiredRatio * (1 - f)) + * Calculate the locked liquidity BPS at a given time (in seconds) after migration + * @param partnerPermanentLockedLiquidityPercentage - Partner's permanently locked liquidity percentage + * @param creatorPermanentLockedLiquidityPercentage - Creator's permanently locked liquidity percentage + * @param partnerLiquidityVestingInfo - Partner's liquidity vesting info (optional) + * @param creatorLiquidityVestingInfo - Creator's liquidity vesting info (optional) + * @param elapsedSeconds - Number of seconds after migration + * @returns The total locked liquidity in BPS (basis points) */ -export function calculateAdjustedPercentageSupplyOnMigration( - initialMarketCap: number, - migrationMarketCap: number, - migrationFee: { feePercentage: number }, - lockedVesting: LockedVestingParameters, - totalLeftover: BN, - totalTokenSupply: BN +export function calculateLockedLiquidityBpsAtTime( + partnerPermanentLockedLiquidityPercentage: number, + creatorPermanentLockedLiquidityPercentage: number, + partnerLiquidityVestingInfo: LiquidityVestingInfoParameters | undefined, + creatorLiquidityVestingInfo: LiquidityVestingInfoParameters | undefined, + elapsedSeconds: number ): number { - const D = new Decimal(initialMarketCap) - const M = new Decimal(migrationMarketCap) - const f = new Decimal(migrationFee.feePercentage).div(100) - - // calculate vesting and leftover percentages - const totalVestingAmount = getTotalVestingAmount(lockedVesting) - const V = new Decimal(totalVestingAmount.toString()) - .mul(100) - .div(new Decimal(totalTokenSupply.toString())) - const L = new Decimal(totalLeftover.toString()) - .mul(100) - .div(new Decimal(totalTokenSupply.toString())) + // calculate vested locked BPS using the same u128 arithmetic as on-chain + const partnerVestedLockedLiquidityBps = + getVestingLockedLiquidityBpsAtNSeconds( + partnerLiquidityVestingInfo, + elapsedSeconds + ) + const creatorVestedLockedLiquidityBps = + getVestingLockedLiquidityBpsAtNSeconds( + creatorLiquidityVestingInfo, + elapsedSeconds + ) - // requiredRatio = sqrt(D / M) - const requiredRatio = Decimal.sqrt(D.div(M)) + const partnerPermanentLockedLiquidityBps = + partnerPermanentLockedLiquidityPercentage * 100 + const creatorPermanentLockedLiquidityBps = + creatorPermanentLockedLiquidityPercentage * 100 - // percentageSupplyOnMigration = (requiredRatio * (1 - f) * (100 - V - L)) / (1 + requiredRatio * (1 - f)) - const oneMinusF = new Decimal(1).sub(f) - const availablePercentage = new Decimal(100).sub(V).sub(L) - const numerator = requiredRatio.mul(oneMinusF).mul(availablePercentage) - const denominator = new Decimal(1).add(requiredRatio.mul(oneMinusF)) - const percentageSupplyOnMigration = numerator.div(denominator).toNumber() + // total locked = partner_vested + partner_permanent + creator_vested + creator_permanent + const totalLockedLiquidityBpsAtNSeconds = + partnerVestedLockedLiquidityBps + + partnerPermanentLockedLiquidityBps + + creatorVestedLockedLiquidityBps + + creatorPermanentLockedLiquidityBps - return percentageSupplyOnMigration + return totalLockedLiquidityBpsAtNSeconds } -/** - * Get the migration quote amount - * @param migrationMarketCap - The migration market cap - * @param percentageSupplyOnMigration - The percentage of supply on migration - * @returns The migration quote amount - */ -export const getMigrationQuoteAmount = ( - migrationMarketCap: Decimal, - percentageSupplyOnMigration: Decimal -): Decimal => { - // migrationMC * x / 100 - return migrationMarketCap - .mul(percentageSupplyOnMigration) - .div(new Decimal(100)) +function exponentialReductionFactor( + maxBaseFeeNumerator: BN, + minBaseFeeNumerator: BN, + numberOfPeriod: number +): BN { + const ratio = new Decimal(minBaseFeeNumerator.toString()).div( + new Decimal(maxBaseFeeNumerator.toString()) + ) + const decayBase = ratio.pow(new Decimal(1).div(numberOfPeriod)) + let reductionFactor = new BN( + new Decimal(MAX_BASIS_POINT) + .mul(new Decimal(1).sub(decayBase)) + .floor() + .toFixed() + ) + if (reductionFactor.isNeg()) { + reductionFactor = new BN(0) + } else if (reductionFactor.gtn(MAX_BASIS_POINT)) { + reductionFactor = new BN(MAX_BASIS_POINT) + } + const feeAt = (factor: BN) => + getFeeNumeratorOnExponentialFeeScheduler( + maxBaseFeeNumerator, + factor, + numberOfPeriod + ) + + while ( + reductionFactor.gtn(0) && + feeAt(reductionFactor).lt(minBaseFeeNumerator) + ) { + reductionFactor = reductionFactor.subn(1) + } + while (reductionFactor.ltn(MAX_BASIS_POINT)) { + const nextFee = feeAt(reductionFactor.addn(1)) + if (nextFee.lt(minBaseFeeNumerator)) { + break + } + reductionFactor = reductionFactor.addn(1) + } + return reductionFactor } /** @@ -917,725 +810,1184 @@ export function getFeeSchedulerParams( throw new Error('Total periods must be greater than zero') } - if (startingBaseFeeBps > MAX_FEE_BPS) { + if (startingBaseFeeBps > MAX_FEE_BPS) { + throw new Error( + `startingBaseFeeBps (${startingBaseFeeBps} bps) exceeds maximum allowed value of ${MAX_FEE_BPS} bps` + ) + } + + if (endingBaseFeeBps < MIN_FEE_BPS) { + throw new Error( + `endingBaseFeeBps (${endingBaseFeeBps} bps) is less than minimum allowed value of ${MIN_FEE_BPS} bps` + ) + } + + if (endingBaseFeeBps > startingBaseFeeBps) { + throw new Error( + 'endingBaseFeeBps bps must be less than or equal to startingBaseFeeBps bps' + ) + } + + if (numberOfPeriod == 0 || totalDuration == 0) { + throw new Error( + 'numberOfPeriod and totalDuration must both greater than zero' + ) + } + + const maxBaseFeeNumerator = bpsToFeeNumerator(startingBaseFeeBps) + + const minBaseFeeNumerator = bpsToFeeNumerator(endingBaseFeeBps) + + const periodFrequency = new BN(totalDuration).div(new BN(numberOfPeriod)) + + let reductionFactor: BN + if (baseFeeMode == BaseFeeMode.FeeSchedulerLinear) { + const totalReduction = maxBaseFeeNumerator.sub(minBaseFeeNumerator) + reductionFactor = totalReduction.divn(numberOfPeriod) + } else { + reductionFactor = exponentialReductionFactor( + maxBaseFeeNumerator, + minBaseFeeNumerator, + numberOfPeriod + ) + } + + return { + cliffFeeNumerator: maxBaseFeeNumerator, + firstFactor: numberOfPeriod, + secondFactor: periodFrequency, + thirdFactor: reductionFactor, + baseFeeMode, + } +} + +/** Ending fee in basis points, using the program's floor(numerator * 10_000 / FEE_DENOMINATOR). */ +export function calculateFeeSchedulerEndingBaseFeeBps( + cliffFeeNumerator: number, + numberOfPeriod: number, + periodFrequency: number, + reductionFactor: number, + baseFeeMode: BaseFeeMode +): number { + if (numberOfPeriod === 0 || periodFrequency === 0) { + return numeratorToBps(cliffFeeNumerator) + } + + const baseFeeNumerator = + baseFeeMode == BaseFeeMode.FeeSchedulerLinear + ? cliffFeeNumerator - numberOfPeriod * reductionFactor + : getFeeNumeratorOnExponentialFeeScheduler( + new BN(Math.floor(cliffFeeNumerator)), + new BN(Math.floor(reductionFactor)), + Math.floor(numberOfPeriod) + ).toNumber() + + return numeratorToBps(baseFeeNumerator) +} + +function numeratorToBps(numerator: number): number { + if (!Number.isFinite(numerator) || numerator <= 0) { + return 0 + } + return feeNumeratorToBps(new BN(Math.floor(numerator))) +} + +/** + * Get the rate limiter parameters. + * @deprecated New configs cannot use RateLimiter. Kept for quoting existing rate-limiter pools. + * @param baseFeeBps - The base fee in basis points + * @param feeIncrementBps - The fee increment in basis points + * @param referenceAmount - The reference amount + * @param maxLimiterDuration - The max rate limiter duration + * @param tokenQuoteDecimal - The token quote decimal + * @param activationType - The activation type + * @returns The rate limiter parameters + */ +export function getRateLimiterParams( + baseFeeBps: number, + feeIncrementBps: number, + referenceAmount: number, + maxLimiterDuration: number, + tokenQuoteDecimal: number, + activationType: ActivationType +): BaseFee { + const cliffFeeNumerator = bpsToFeeNumerator(baseFeeBps) + const feeIncrementNumerator = bpsToFeeNumerator(feeIncrementBps) + + if ( + baseFeeBps <= 0 || + feeIncrementBps <= 0 || + referenceAmount <= 0 || + maxLimiterDuration <= 0 + ) { + throw new Error('All rate limiter parameters must be greater than zero') + } + + if (baseFeeBps > MAX_FEE_BPS) { + throw new Error( + `Base fee (${baseFeeBps} bps) exceeds maximum allowed value of ${MAX_FEE_BPS} bps` + ) + } + + if (baseFeeBps < MIN_FEE_BPS) { + throw new Error( + `Base fee (${baseFeeBps} bps) is less than minimum allowed value of ${MIN_FEE_BPS} bps` + ) + } + + if (feeIncrementBps > MAX_FEE_BPS) { + throw new Error( + `Fee increment (${feeIncrementBps} bps) exceeds maximum allowed value of ${MAX_FEE_BPS} bps` + ) + } + + if (feeIncrementNumerator.gte(new BN(FEE_DENOMINATOR))) { + throw new Error( + 'Fee increment numerator must be less than FEE_DENOMINATOR' + ) + } + + const deltaNumerator = new BN(MAX_FEE_NUMERATOR).sub(cliffFeeNumerator) + const maxIndex = deltaNumerator.div(feeIncrementNumerator) + if (maxIndex.lt(new BN(1))) { + throw new Error('Fee increment is too large for the given base fee') + } + + if ( + cliffFeeNumerator.lt(new BN(MIN_FEE_NUMERATOR)) || + cliffFeeNumerator.gt(new BN(MAX_FEE_NUMERATOR)) + ) { + throw new Error('Base fee must be between 0.01% and 99%') + } + + const maxDuration = + activationType === ActivationType.Slot + ? MAX_RATE_LIMITER_DURATION_IN_SLOTS + : MAX_RATE_LIMITER_DURATION_IN_SECONDS + + if (maxLimiterDuration > maxDuration) { + throw new Error( + `Max duration exceeds maximum allowed value of ${maxDuration}` + ) + } + + const referenceAmountInLamports = convertToLamports( + referenceAmount, + tokenQuoteDecimal + ) + + return { + cliffFeeNumerator, + firstFactor: feeIncrementBps, + secondFactor: new BN(maxLimiterDuration), + thirdFactor: new BN(referenceAmountInLamports), + baseFeeMode: BaseFeeMode.RateLimiter, + } +} + +/** + * Get the dynamic fee parameters (20% of base fee) + * @param baseFeeBps - The base fee in basis points + * @param maxPriceChangeBps - The max price change in basis points + * @returns The dynamic fee parameters + */ +export function getDynamicFeeParams( + baseFeeBps: number, + maxPriceChangeBps: number = MAX_PRICE_CHANGE_BPS_DEFAULT // default 15% +): DynamicFeeParameters { + if (maxPriceChangeBps > MAX_PRICE_CHANGE_BPS_DEFAULT) { + throw new Error( + `maxPriceChangeBps (${maxPriceChangeBps} bps) must be less than or equal to ${MAX_PRICE_CHANGE_BPS_DEFAULT}` + ) + } + + const priceRatio = maxPriceChangeBps / MAX_BASIS_POINT + 1 + // Q64 + const sqrtPriceRatioQ64 = new BN( + Decimal.sqrt(priceRatio.toString()) + .mul(Decimal.pow(2, 64)) + .floor() + .toFixed() + ) + const deltaBinId = sqrtPriceRatioQ64 + .sub(ONE_Q64) + .div(BIN_STEP_BPS_U128_DEFAULT) + .muln(2) + + const maxVolatilityAccumulator = new BN(deltaBinId.muln(MAX_BASIS_POINT)) + + const squareVfaBin = maxVolatilityAccumulator + .mul(new BN(BIN_STEP_BPS_DEFAULT)) + .pow(new BN(2)) + + const baseFeeNumerator = new BN(bpsToFeeNumerator(baseFeeBps)) + const maxDynamicFeeNumerator = baseFeeNumerator.muln(20).divn(100) // default max dynamic fee = 20% of base fee. + const vFee = maxDynamicFeeNumerator + .mul(new BN(100_000_000_000)) + .sub(new BN(99_999_999_999)) + + const variableFeeControl = vFee.div(squareVfaBin) + + return { + binStep: BIN_STEP_BPS_DEFAULT, + binStepU128: BIN_STEP_BPS_U128_DEFAULT, + filterPeriod: DYNAMIC_FEE_FILTER_PERIOD_DEFAULT, + decayPeriod: DYNAMIC_FEE_DECAY_PERIOD_DEFAULT, + reductionFactor: DYNAMIC_FEE_REDUCTION_FACTOR_DEFAULT, + maxVolatilityAccumulator: maxVolatilityAccumulator.toNumber(), + variableFeeControl: variableFeeControl.toNumber(), + } +} +/** + * Derive the starting base fee BPS from baseFeeParams + * For FeeSchedulerLinear/FeeSchedulerExponential: uses endingFeeBps (the fee at end of pre-migration curve) + * For RateLimiter: uses baseFeeBps (the cliff fee) + * @param baseFeeParams - The base fee parameters from the pre-migration pool + * @returns The starting base fee in basis points for the migrated pool + */ +export function getStartingBaseFeeBpsFromBaseFeeParams( + baseFeeParams: BaseFeeParams +): number { + if (baseFeeParams.baseFeeMode === BaseFeeMode.RateLimiter) { + return baseFeeParams.rateLimiterParam.baseFeeBps + } else { + return baseFeeParams.feeSchedulerParam.endingFeeBps + } +} + +/** + * Get the migrated pool market cap fee scheduler parameters + * @param startingBaseFeeBps - Starting (max) fee in basis points + * @param endingBaseFeeBps - Ending (min) fee in basis points + * @param baseFeeMode - Linear or exponential decay + * @param numberOfPeriod - Number of fee reduction periods + * @param priceMultiple - Target spot-price multiple from the initial price (e.g. 1000 for 1000x). Must be > 1. + * @param schedulerExpirationDuration - Seconds after which the schedule expires to the ending fee regardless of price + * @returns The migrated pool market cap fee scheduler parameters + */ +export function getMigratedPoolMarketCapFeeSchedulerParams( + startingBaseFeeBps: number, + endingBaseFeeBps: number, + dammV2BaseFeeMode: DammV2BaseFeeMode, + numberOfPeriod: number, + priceMultiple: number, + schedulerExpirationDuration: number +): MigratedPoolMarketCapFeeSchedulerParameters { + if ( + dammV2BaseFeeMode === DammV2BaseFeeMode.FeeTimeSchedulerLinear || + dammV2BaseFeeMode === DammV2BaseFeeMode.FeeTimeSchedulerExponential + ) { + return DEFAULT_MIGRATED_POOL_MARKET_CAP_FEE_SCHEDULER_PARAMS + } + + if (dammV2BaseFeeMode === DammV2BaseFeeMode.RateLimiter) { + throw new Error( + 'RateLimiter is not supported for DAMM v2 migration. Use either FeeMarketCapSchedulerLinear or FeeMarketCapSchedulerExponential instead.' + ) + } + + if (numberOfPeriod <= 0) { + throw new Error('Total periods must be greater than zero') + } + + const poolMaxFeeBps = MAX_FEE_BPS + + if (startingBaseFeeBps <= endingBaseFeeBps) { throw new Error( - `startingBaseFeeBps (${startingBaseFeeBps} bps) exceeds maximum allowed value of ${MAX_FEE_BPS} bps` + `startingBaseFeeBps (${startingBaseFeeBps} bps) must be greater than endingBaseFeeBps (${endingBaseFeeBps} bps)` ) } - if (endingBaseFeeBps < MIN_FEE_BPS) { - throw new Error( - `endingBaseFeeBps (${endingBaseFeeBps} bps) is less than minimum allowed value of ${MIN_FEE_BPS} bps` - ) + if (priceMultiple <= 1) { + throw new Error('priceMultiple must be greater than 1') } - if (endingBaseFeeBps > startingBaseFeeBps) { + if (startingBaseFeeBps > poolMaxFeeBps) { throw new Error( - 'endingBaseFeeBps bps must be less than or equal to startingBaseFeeBps bps' + `startingBaseFeeBps (${startingBaseFeeBps} bps) exceeds maximum allowed value of ${poolMaxFeeBps} bps` ) } - if (numberOfPeriod == 0 || totalDuration == 0) { - throw new Error( - 'numberOfPeriod and totalDuration must both greater than zero' - ) + if (schedulerExpirationDuration == 0) { + throw new Error('schedulerExpirationDuration must be greater than zero') } - const maxBaseFeeNumerator = bpsToFeeNumerator(startingBaseFeeBps) + const sqrtPriceStepBps = computeSqrtPriceStepBps( + priceMultiple, + numberOfPeriod + ) + const maxBaseFeeNumerator = bpsToFeeNumerator(startingBaseFeeBps) const minBaseFeeNumerator = bpsToFeeNumerator(endingBaseFeeBps) - const periodFrequency = new BN(totalDuration / numberOfPeriod) - let reductionFactor: BN - if (baseFeeMode == BaseFeeMode.FeeSchedulerLinear) { + + if (dammV2BaseFeeMode === DammV2BaseFeeMode.FeeMarketCapSchedulerLinear) { const totalReduction = maxBaseFeeNumerator.sub(minBaseFeeNumerator) reductionFactor = totalReduction.divn(numberOfPeriod) - } else { - const ratio = new Decimal(minBaseFeeNumerator.toString()).div( - new Decimal(maxBaseFeeNumerator.toString()) + } else if ( + dammV2BaseFeeMode === DammV2BaseFeeMode.FeeMarketCapSchedulerExponential + ) { + reductionFactor = exponentialReductionFactor( + maxBaseFeeNumerator, + minBaseFeeNumerator, + numberOfPeriod ) - const decayBase = ratio.pow(new Decimal(1).div(numberOfPeriod)) - reductionFactor = new BN( - new Decimal(MAX_BASIS_POINT) - .mul(new Decimal(1).sub(decayBase)) - .floor() - .toFixed() + } else { + throw new Error( + 'Migrated market-cap fee scheduler requires a market-cap scheduler base fee mode' ) } return { - cliffFeeNumerator: maxBaseFeeNumerator, - firstFactor: numberOfPeriod, - secondFactor: periodFrequency, - thirdFactor: reductionFactor, - baseFeeMode, + numberOfPeriod, + sqrtPriceStepBps, + schedulerExpirationDuration, + reductionFactor, } } /** - * Calculate the ending base fee of fee scheduler in basis points - * @param cliffFeeNumerator - The cliff fee numerator - * @param numberOfPeriod - The number of period - * @param reductionFactor - The reduction factor - * @param feeSchedulerMode - The fee scheduler mode - * @returns The minimum base fee in basis points + * Check if rate limiter should be applied based on pool configuration and state + * @param baseFeeMode - The base fee mode + * @param swapBaseForQuote - Whether the swap is from base to quote + * @param currentPoint - The current point + * @param activationPoint - The activation point + * @param maxLimiterDuration - The maximum limiter duration + * @returns Whether rate limiter should be applied */ -export function calculateFeeSchedulerEndingBaseFeeBps( - cliffFeeNumerator: number, - numberOfPeriod: number, - periodFrequency: number, - reductionFactor: number, - baseFeeMode: BaseFeeMode -): number { - if (numberOfPeriod === 0 || periodFrequency === 0) { - return (cliffFeeNumerator / FEE_DENOMINATOR) * MAX_BASIS_POINT - } - - let baseFeeNumerator: number - if (baseFeeMode == BaseFeeMode.FeeSchedulerLinear) { - // linear mode - baseFeeNumerator = cliffFeeNumerator - numberOfPeriod * reductionFactor - } else { - // exponential mode - const decayRate = new Decimal(1).sub( - new Decimal(reductionFactor).div(MAX_BASIS_POINT) - ) - baseFeeNumerator = new Decimal(cliffFeeNumerator) - .mul(decayRate.pow(numberOfPeriod)) - .toNumber() - } - - // ensure base fee is not negative - return Math.max(0, (baseFeeNumerator / FEE_DENOMINATOR) * MAX_BASIS_POINT) +export function checkRateLimiterApplied( + baseFeeMode: BaseFeeMode, + swapBaseForQuote: boolean, + currentPoint: BN, + activationPoint: BN, + maxLimiterDuration: BN +): boolean { + return ( + baseFeeMode === BaseFeeMode.RateLimiter && + !swapBaseForQuote && + currentPoint.gte(activationPoint) && + currentPoint.lte(activationPoint.add(maxLimiterDuration)) + ) } /** - * Get the rate limiter parameters. - * @deprecated New configs cannot use RateLimiter. Kept for quoting existing rate-limiter pools. - * @param baseFeeBps - The base fee in basis points - * @param feeIncrementBps - The fee increment in basis points - * @param referenceAmount - The reference amount - * @param maxLimiterDuration - The max rate limiter duration - * @param tokenQuoteDecimal - The token quote decimal - * @param activationType - The activation type - * @returns The rate limiter parameters + * Get base fee parameters based on the base fee mode + * @param baseFeeParams - The base fee parameters + * @returns The base fee parameters */ -export function getRateLimiterParams( - baseFeeBps: number, - feeIncrementBps: number, - referenceAmount: number, - maxLimiterDuration: number, - tokenQuoteDecimal: number, - activationType: ActivationType -): BaseFee { - const cliffFeeNumerator = bpsToFeeNumerator(baseFeeBps) - const feeIncrementNumerator = bpsToFeeNumerator(feeIncrementBps) - - if ( - baseFeeBps <= 0 || - feeIncrementBps <= 0 || - referenceAmount <= 0 || - maxLimiterDuration <= 0 - ) { - throw new Error('All rate limiter parameters must be greater than zero') - } - - if (baseFeeBps > MAX_FEE_BPS) { +export function getBaseFeeParams(baseFeeParams: BaseFeeParams): BaseFee { + if (baseFeeParams.baseFeeMode === BaseFeeMode.RateLimiter) { throw new Error( - `Base fee (${baseFeeBps} bps) exceeds maximum allowed value of ${MAX_FEE_BPS} bps` + 'BaseFeeMode.RateLimiter is deprecated. New configs must use FeeSchedulerLinear or FeeSchedulerExponential.' ) } - if (baseFeeBps < MIN_FEE_BPS) { - throw new Error( - `Base fee (${baseFeeBps} bps) is less than minimum allowed value of ${MIN_FEE_BPS} bps` - ) - } + const { startingFeeBps, endingFeeBps, numberOfPeriod, totalDuration } = + baseFeeParams.feeSchedulerParam - if (feeIncrementBps > MAX_FEE_BPS) { - throw new Error( - `Fee increment (${feeIncrementBps} bps) exceeds maximum allowed value of ${MAX_FEE_BPS} bps` - ) + return getFeeSchedulerParams( + startingFeeBps, + endingFeeBps, + baseFeeParams.baseFeeMode, + numberOfPeriod, + totalDuration + ) +} + +/** + * Get migrated pool fee parameters based on migration options + * @param migrationOption - The migration option (DAMM or DAMM_V2) + * @param migrationFeeOption - The fee option (fixed rates 0-5 or customizable) + * @param migratedPoolFee - Optional custom migrated pool fee parameters (only used with DAMM_V2 + Customizable) + * @returns Migrated pool fee parameters with appropriate defaults + */ +export function getMigratedPoolFeeParams( + migrationOption: MigrationOption, + migrationFeeOption: MigrationFeeOption, + migratedPoolFee?: MigratedPoolFeeConfig, + baseFeeParams?: BaseFeeParams +): MigratedPoolFeeResult { + const defaultResult: MigratedPoolFeeResult = { + migratedPoolFee: DEFAULT_MIGRATED_POOL_FEE_PARAMS, + migratedPoolBaseFeeMode: DammV2BaseFeeMode.FeeTimeSchedulerLinear, + migratedPoolMarketCapFeeSchedulerParams: + DEFAULT_MIGRATED_POOL_MARKET_CAP_FEE_SCHEDULER_PARAMS, + migrationFeeOption, + compoundingFeeBps: 0, } - if (feeIncrementNumerator.gte(new BN(FEE_DENOMINATOR))) { + if (migrationOption === MigrationOption.MET_DAMM) { throw new Error( - 'Fee increment numerator must be less than FEE_DENOMINATOR' + 'MigrationOption.MET_DAMM (DAMM v1) is deprecated. New configs must use MigrationOption.MET_DAMM_V2.' ) } - const deltaNumerator = new BN(MAX_FEE_NUMERATOR).sub(cliffFeeNumerator) - const maxIndex = deltaNumerator.div(feeIncrementNumerator) - if (maxIndex.lt(new BN(1))) { - throw new Error('Fee increment is too large for the given base fee') - } - - if ( - cliffFeeNumerator.lt(new BN(MIN_FEE_NUMERATOR)) || - cliffFeeNumerator.gt(new BN(MAX_FEE_NUMERATOR)) - ) { - throw new Error('Base fee must be between 0.01% and 99%') - } + // for DAMM_V2: use custom parameters based on configuration + if (migrationOption === MigrationOption.MET_DAMM_V2) { + const baseFeeMode = + migratedPoolFee?.baseFeeMode ?? + DammV2BaseFeeMode.FeeTimeSchedulerLinear - const maxDuration = - activationType === ActivationType.Slot - ? MAX_RATE_LIMITER_DURATION_IN_SLOTS - : MAX_RATE_LIMITER_DURATION_IN_SECONDS + // when marketCapFeeSchedulerParams is configured, use custom values + if (migratedPoolFee?.marketCapFeeSchedulerParams && baseFeeParams) { + const schedulerParams = getMigratedPoolMarketCapFeeSchedulerParams( + migratedPoolFee.poolFeeBps, + migratedPoolFee.marketCapFeeSchedulerParams.endingBaseFeeBps, + baseFeeMode, + migratedPoolFee.marketCapFeeSchedulerParams.numberOfPeriod, + migratedPoolFee.marketCapFeeSchedulerParams.priceMultiple, + migratedPoolFee.marketCapFeeSchedulerParams + .schedulerExpirationDuration + ) - if (maxLimiterDuration > maxDuration) { - throw new Error( - `Max duration exceeds maximum allowed value of ${maxDuration}` - ) - } + return { + migratedPoolFee: { + collectFeeMode: migratedPoolFee.collectFeeMode, + dynamicFee: migratedPoolFee.dynamicFee, + poolFeeBps: migratedPoolFee.poolFeeBps, + }, + migratedPoolBaseFeeMode: baseFeeMode, + migratedPoolMarketCapFeeSchedulerParams: schedulerParams, + migrationFeeOption: MigrationFeeOption.Customizable, + compoundingFeeBps: migratedPoolFee.compoundingFeeBps ?? 0, + } + } - const referenceAmountInLamports = convertToLamports( - referenceAmount, - tokenQuoteDecimal - ) + // use custom parameters if Customizable option is selected + if (migrationFeeOption === MigrationFeeOption.Customizable) { + if (migratedPoolFee?.poolFeeBps === undefined) { + throw new Error( + 'migratedPoolFee.poolFeeBps is required when migrationFeeOption is Customizable' + ) + } + return { + migratedPoolFee: { + collectFeeMode: + migratedPoolFee?.collectFeeMode ?? + DEFAULT_MIGRATED_POOL_FEE_PARAMS.collectFeeMode, + dynamicFee: + migratedPoolFee?.dynamicFee ?? + DEFAULT_MIGRATED_POOL_FEE_PARAMS.dynamicFee, + poolFeeBps: + migratedPoolFee?.poolFeeBps ?? + DEFAULT_MIGRATED_POOL_FEE_PARAMS.poolFeeBps, + }, + migratedPoolBaseFeeMode: baseFeeMode, + migratedPoolMarketCapFeeSchedulerParams: + DEFAULT_MIGRATED_POOL_MARKET_CAP_FEE_SCHEDULER_PARAMS, + migrationFeeOption: MigrationFeeOption.Customizable, + compoundingFeeBps: migratedPoolFee?.compoundingFeeBps ?? 0, + } + } - return { - cliffFeeNumerator, - firstFactor: feeIncrementBps, - secondFactor: new BN(maxLimiterDuration), - thirdFactor: new BN(referenceAmountInLamports), - baseFeeMode: BaseFeeMode.RateLimiter, + // for fixed fee options (0-5), use defaults but preserve baseFeeMode if provided + return { + migratedPoolFee: DEFAULT_MIGRATED_POOL_FEE_PARAMS, + migratedPoolBaseFeeMode: baseFeeMode, + migratedPoolMarketCapFeeSchedulerParams: + DEFAULT_MIGRATED_POOL_MARKET_CAP_FEE_SCHEDULER_PARAMS, + migrationFeeOption, + compoundingFeeBps: 0, + } } + + return defaultResult } /** - * Get the dynamic fee parameters (20% of base fee) - * @param baseFeeBps - The base fee in basis points - * @param maxPriceChangeBps - The max price change in basis points - * @returns The dynamic fee parameters + * Get the total token supply + * @param swapBaseAmount - The swap base amount + * @param migrationBaseThreshold - The migration base threshold + * @param lockedVestingParams - The locked vesting parameters + * @returns The total token supply */ -export function getDynamicFeeParams( - baseFeeBps: number, - maxPriceChangeBps: number = MAX_PRICE_CHANGE_BPS_DEFAULT // default 15% -): DynamicFeeParameters { - if (maxPriceChangeBps > MAX_PRICE_CHANGE_BPS_DEFAULT) { - throw new Error( - `maxPriceChangeBps (${maxPriceChangeBps} bps) must be less than or equal to ${MAX_PRICE_CHANGE_BPS_DEFAULT}` - ) +export function getTotalTokenSupply( + swapBaseAmount: BN, + migrationBaseThreshold: BN, + lockedVestingParams: { + amountPerPeriod: BN + numberOfPeriod: BN + cliffUnlockAmount: BN } +): BN { + try { + // calculate total circulating amount + const totalCirculatingAmount = swapBaseAmount.add( + migrationBaseThreshold + ) - const priceRatio = maxPriceChangeBps / MAX_BASIS_POINT + 1 - // Q64 - const sqrtPriceRatioQ64 = new BN( - Decimal.sqrt(priceRatio.toString()) - .mul(Decimal.pow(2, 64)) - .floor() - .toFixed() - ) - const deltaBinId = sqrtPriceRatioQ64 - .sub(ONE_Q64) - .div(BIN_STEP_BPS_U128_DEFAULT) - .muln(2) - - const maxVolatilityAccumulator = new BN(deltaBinId.muln(MAX_BASIS_POINT)) - - const squareVfaBin = maxVolatilityAccumulator - .mul(new BN(BIN_STEP_BPS_DEFAULT)) - .pow(new BN(2)) + // calculate total locked vesting amount + const totalLockedVestingAmount = + lockedVestingParams.cliffUnlockAmount.add( + lockedVestingParams.amountPerPeriod.mul( + lockedVestingParams.numberOfPeriod + ) + ) - const baseFeeNumerator = new BN(bpsToFeeNumerator(baseFeeBps)) - const maxDynamicFeeNumerator = baseFeeNumerator.muln(20).divn(100) // default max dynamic fee = 20% of base fee. - const vFee = maxDynamicFeeNumerator - .mul(new BN(100_000_000_000)) - .sub(new BN(99_999_999_999)) + // calculate total amount + const totalAmount = totalCirculatingAmount.add(totalLockedVestingAmount) - const variableFeeControl = vFee.div(squareVfaBin) + // check for overflow + if (totalAmount.isNeg() || totalAmount.bitLength() > 64) { + throw new Error('Math overflow') + } - return { - binStep: BIN_STEP_BPS_DEFAULT, - binStepU128: BIN_STEP_BPS_U128_DEFAULT, - filterPeriod: DYNAMIC_FEE_FILTER_PERIOD_DEFAULT, - decayPeriod: DYNAMIC_FEE_DECAY_PERIOD_DEFAULT, - reductionFactor: DYNAMIC_FEE_REDUCTION_FACTOR_DEFAULT, - maxVolatilityAccumulator: maxVolatilityAccumulator.toNumber(), - variableFeeControl: variableFeeControl.toNumber(), + return totalAmount + } catch (error) { + throw new Error(`Math overflow: ${error}`) } } + /** - * Derive the starting base fee BPS from baseFeeParams - * For FeeSchedulerLinear/FeeSchedulerExponential: uses endingFeeBps (the fee at end of pre-migration curve) - * For RateLimiter: uses baseFeeBps (the cliff fee) - * @param baseFeeParams - The base fee parameters from the pre-migration pool - * @returns The starting base fee in basis points for the migrated pool + * Get migrationQuoteAmount from migrationQuoteThreshold and migrationFeePercent + * @param migrationQuoteThreshold - The migration quote threshold + * @param migrationFeePercent - The migration fee percent + * @returns migration quote amount to deposit to pool */ -export function getStartingBaseFeeBpsFromBaseFeeParams( - baseFeeParams: BaseFeeParams -): number { - if (baseFeeParams.baseFeeMode === BaseFeeMode.RateLimiter) { - return baseFeeParams.rateLimiterParam.baseFeeBps - } else { - return baseFeeParams.feeSchedulerParam.endingFeeBps - } +export const getMigrationQuoteAmountFromMigrationQuoteThreshold = ( + migrationQuoteThreshold: Decimal, + migrationFeePercent: number +): Decimal => { + const migrationQuoteAmount = migrationQuoteThreshold + .mul(new Decimal(100).sub(new Decimal(migrationFeePercent))) + .div(new Decimal(100)) + return migrationQuoteAmount } /** - * Computes the sqrtPriceStepBps needed so that the fee schedule is fully - * exhausted when spot price reaches a given multiple of the initial price. - * @param priceMultiple - Target spot-price multiple (e.g. 1000 for 1000x) - * @param numberOfPeriod - Number of fee reduction periods - * @returns The sqrtPriceStepBps value to use on-chain + * Get migrationQuoteThreshold from migrationQuoteAmount and migrationFeePercent + * @param migrationQuoteAmount - The migration quote amount + * @param migrationFeePercent - The migration fee percent + * @returns migration quote threshold on bonding curve */ -export function computeSqrtPriceStepBps( - priceMultiple: number, - numberOfPeriod: number -): number { - if (priceMultiple <= 1) { - throw new Error('priceMultiple must be greater than 1') - } - if (numberOfPeriod <= 0) { - throw new Error('numberOfPeriod must be greater than 0') - } - const sqrtPriceStepBps = Math.floor( - ((Math.sqrt(priceMultiple) - 1) * MAX_BASIS_POINT) / numberOfPeriod - ) - if (sqrtPriceStepBps <= 0) { - throw new Error( - 'Computed sqrtPriceStepBps is 0 — increase priceMultiple or decrease numberOfPeriod' - ) - } - return sqrtPriceStepBps +export const getMigrationQuoteThresholdFromMigrationQuoteAmount = ( + migrationQuoteAmount: Decimal, + migrationFeePercent: Decimal +): Decimal => { + const migrationQuoteThreshold = migrationQuoteAmount + .mul(new Decimal(100)) + .div(new Decimal(100).sub(new Decimal(migrationFeePercent))) + return migrationQuoteThreshold } /** - * Get the migrated pool market cap fee scheduler parameters - * @param startingBaseFeeBps - Starting (max) fee in basis points - * @param endingBaseFeeBps - Ending (min) fee in basis points - * @param baseFeeMode - Linear or exponential decay - * @param numberOfPeriod - Number of fee reduction periods - * @param priceMultiple - Target spot-price multiple from the initial price (e.g. 1000 for 1000x). Must be > 1. - * @param schedulerExpirationDuration - Seconds after which the schedule expires to the ending fee regardless of price - * @returns The migrated pool market cap fee scheduler parameters + * Calculates the protocol migration fee for both base and quote tokens. + * + * @param depositBaseAmount - Amount of base token to deposit in pool (BN) + * @param depositQuoteAmount - Amount of quote token to deposit in pool (BN) + * @param migrationSqrtPrice - Migration sqrt price (BN) + * @param migrationFeeBps - Migration fee in basis points (number) + * @param migrationOption - Migration option (MigrationOption, enum) + * @param migratedCollectFeeMode - Migrated DAMM v2 collect fee mode + * @returns [baseFeeAmount: BN, quoteFeeAmount: BN] */ -export function getMigratedPoolMarketCapFeeSchedulerParams( - startingBaseFeeBps: number, - endingBaseFeeBps: number, - dammV2BaseFeeMode: DammV2BaseFeeMode, - numberOfPeriod: number, - priceMultiple: number, - schedulerExpirationDuration: number -): MigratedPoolMarketCapFeeSchedulerParameters { - if ( - dammV2BaseFeeMode === DammV2BaseFeeMode.FeeTimeSchedulerLinear || - dammV2BaseFeeMode === DammV2BaseFeeMode.FeeTimeSchedulerExponential - ) { - return DEFAULT_MIGRATED_POOL_MARKET_CAP_FEE_SCHEDULER_PARAMS - } - - if (dammV2BaseFeeMode === DammV2BaseFeeMode.RateLimiter) { - throw new Error( - 'RateLimiter is not supported for DAMM v2 migration. Use either FeeMarketCapSchedulerLinear or FeeMarketCapSchedulerExponential instead.' - ) - } - - if (numberOfPeriod <= 0) { - throw new Error('Total periods must be greater than zero') - } - - const poolMaxFeeBps = MAX_FEE_BPS - - if (startingBaseFeeBps <= endingBaseFeeBps) { - throw new Error( - `startingBaseFeeBps (${startingBaseFeeBps} bps) must be greater than endingBaseFeeBps (${endingBaseFeeBps} bps)` - ) - } - - if (priceMultiple <= 1) { - throw new Error('priceMultiple must be greater than 1') - } - - if (startingBaseFeeBps > poolMaxFeeBps) { - throw new Error( - `startingBaseFeeBps (${startingBaseFeeBps} bps) exceeds maximum allowed value of ${poolMaxFeeBps} bps` - ) - } - - if (schedulerExpirationDuration == 0) { - throw new Error('schedulerExpirationDuration must be greater than zero') - } - - const sqrtPriceStepBps = computeSqrtPriceStepBps( - priceMultiple, - numberOfPeriod +export function getProtocolMigrationFee( + depositBaseAmount: BN, + depositQuoteAmount: BN, + migrationSqrtPrice: BN, + migrationFeeBps: number, + migrationOption: MigrationOption, + migratedCollectFeeMode: MigratedCollectFeeMode = MigratedCollectFeeMode.QuoteToken +): [BN, BN] { + // quote fee amount = (depositQuoteAmount * migrationFeeBps) / MAX_BASIS_POINT + const quoteFeeAmount = mulDiv( + depositQuoteAmount, + new BN(migrationFeeBps), + new BN(MAX_BASIS_POINT), + Rounding.Down ) - const maxBaseFeeNumerator = bpsToFeeNumerator(startingBaseFeeBps) - const minBaseFeeNumerator = bpsToFeeNumerator(endingBaseFeeBps) - - let reductionFactor: BN - - if (dammV2BaseFeeMode === DammV2BaseFeeMode.FeeMarketCapSchedulerLinear) { - const totalReduction = maxBaseFeeNumerator.sub(minBaseFeeNumerator) - reductionFactor = totalReduction.divn(numberOfPeriod) - } else if ( - dammV2BaseFeeMode === DammV2BaseFeeMode.FeeMarketCapSchedulerExponential + if ( + migrationOption === MigrationOption.MET_DAMM || + migratedCollectFeeMode === MigratedCollectFeeMode.Compounding ) { - const ratio = - minBaseFeeNumerator.toNumber() / maxBaseFeeNumerator.toNumber() - const decayBase = Math.pow(ratio, 1 / numberOfPeriod) - reductionFactor = new BN(MAX_BASIS_POINT * (1 - decayBase)) - } - - return { - numberOfPeriod, - sqrtPriceStepBps, - schedulerExpirationDuration, - reductionFactor, + // DAMM v1 and compounding DAMM v2 migration: fee as same ratio for base + const baseFeeAmount = mulDiv( + depositBaseAmount, + new BN(migrationFeeBps), + new BN(MAX_BASIS_POINT), + Rounding.Down + ) + return [baseFeeAmount, quoteFeeAmount] + } else if (migrationOption === MigrationOption.MET_DAMM_V2) { + // DAMM v2 migration + const feeLiquidity = getInitialLiquidityFromDeltaQuote( + quoteFeeAmount, + MIN_SQRT_PRICE, + migrationSqrtPrice + ) + const baseFeeAmount = getDeltaAmountBaseUnsigned( + migrationSqrtPrice, + MAX_SQRT_PRICE, + feeLiquidity, + Rounding.Down + ) + return [baseFeeAmount, quoteFeeAmount] + } else { + throw new Error('Invalid migration option') } } /** - * Calculate the locked vesting parameters - * @param totalLockedVestingAmount - The total vesting amount - * @param numberOfVestingPeriod - The number of periods - * @param cliffUnlockAmount - The amount to unlock at cliff - * @param totalVestingDuration - The total duration of vesting - * @param cliffDurationFromMigrationTime - The cliff duration from migration time - * @param tokenBaseDecimal - The decimal of the base token - * @returns The locked vesting parameters - * total_locked_vesting_amount = cliff_unlock_amount + (amount_per_period * number_of_period) + * Get the constant product base amount for a quote amount at a sqrt price + * @param migrationQuoteAmount - The migration quote amount + * @param sqrtMigrationPrice - The migration sqrt price + * @returns The base amount, rounded up */ -export function getLockedVestingParams( - totalLockedVestingAmount: number, - numberOfVestingPeriod: number, - cliffUnlockAmount: number, - totalVestingDuration: number, - cliffDurationFromMigrationTime: number, - tokenBaseDecimal: TokenDecimal -): LockedVestingParameters { - if (totalLockedVestingAmount == 0) { - return { - amountPerPeriod: new BN(0), - cliffDurationFromMigrationTime: new BN(0), - frequency: new BN(0), - numberOfPeriod: new BN(0), - cliffUnlockAmount: new BN(0), - } - } - - if (totalLockedVestingAmount == cliffUnlockAmount) { - return { - amountPerPeriod: convertToLamports(1, tokenBaseDecimal), - cliffDurationFromMigrationTime: new BN( - cliffDurationFromMigrationTime - ), - frequency: new BN(1), - numberOfPeriod: new BN(1), - cliffUnlockAmount: convertToLamports( - totalLockedVestingAmount - 1, - tokenBaseDecimal - ), - } +export function constantProductBaseFromQuote( + migrationQuoteAmount: BN, + sqrtMigrationPrice: BN +): BN { + if (sqrtMigrationPrice.isZero()) { + throw new Error('Math overflow') } - - if (numberOfVestingPeriod <= 0) { - throw new Error('Total periods must be greater than zero') + const price = sqrtMigrationPrice.mul(sqrtMigrationPrice) + const quote = migrationQuoteAmount.shln(128) + const { div, mod } = quote.divmod(price) + const base = mod.isZero() ? div : div.add(new BN(1)) + if (base.gt(U64_MAX)) { + throw new Error('Math overflow') } + return base +} - if (numberOfVestingPeriod == 0 || totalVestingDuration == 0) { - throw new Error( - 'numberOfPeriod and totalVestingDuration must both be greater than zero' +/** + * Get the base token for migration + * @param migrationQuoteAmount - The migration quote amount to deposit to pool + * @param sqrtMigrationPrice - The migration sqrt price + * @param migrationOption - The migration option + * @param migratedCollectFeeMode - Migrated DAMM v2 collect fee mode. Compounding uses constant product. + * @returns The base token + */ +export const getMigrationBaseToken = ( + migrationQuoteAmount: BN, + sqrtMigrationPrice: BN, + migrationOption: MigrationOption, + migratedCollectFeeMode: MigratedCollectFeeMode = MigratedCollectFeeMode.QuoteToken +): BN => { + if ( + migrationOption == MigrationOption.MET_DAMM || + migratedCollectFeeMode === MigratedCollectFeeMode.Compounding + ) { + return constantProductBaseFromQuote( + migrationQuoteAmount, + sqrtMigrationPrice ) - } - - if (cliffUnlockAmount > totalLockedVestingAmount) { - throw new Error( - 'Cliff unlock amount cannot be greater than total locked vesting amount' + } else if (migrationOption == MigrationOption.MET_DAMM_V2) { + const liquidity = getInitialLiquidityFromDeltaQuote( + migrationQuoteAmount, + MIN_SQRT_PRICE, + sqrtMigrationPrice ) - } - - // amount_per_period = (total_locked_vesting_amount - cliff_unlock_amount) / number_of_period - const amountPerPeriod = - (totalLockedVestingAmount - cliffUnlockAmount) / numberOfVestingPeriod - - // round amountPerPeriod down to ensure we don't exceed total amount - const roundedAmountPerPeriod = Math.floor(amountPerPeriod) - - // calculate the remainder from rounding down - const totalPeriodicAmount = roundedAmountPerPeriod * numberOfVestingPeriod - const remainder = - totalLockedVestingAmount - (cliffUnlockAmount + totalPeriodicAmount) - - // add the remainder to cliffUnlockAmount to maintain total amount - const adjustedCliffUnlockAmount = cliffUnlockAmount + remainder - - const periodFrequency = new BN(totalVestingDuration / numberOfVestingPeriod) - - return { - amountPerPeriod: convertToLamports( - roundedAmountPerPeriod, - tokenBaseDecimal - ), - cliffDurationFromMigrationTime: new BN(cliffDurationFromMigrationTime), - frequency: periodFrequency, - numberOfPeriod: new BN(numberOfVestingPeriod), - cliffUnlockAmount: convertToLamports( - adjustedCliffUnlockAmount, - tokenBaseDecimal - ), + // calculate base threshold + const baseAmount = getDeltaAmountBaseUnsigned( + sqrtMigrationPrice, + MAX_SQRT_PRICE, + liquidity, + Rounding.Up + ) + return baseAmount + } else { + throw Error('Invalid migration option') } } -export const getLiquidityVestingInfoParams = ( - vestingPercentage: number, - bpsPerPeriod: number, - numberOfPeriods: number, - cliffDurationFromMigrationTime: number, - totalDuration: number -): LiquidityVestingInfoParameters => { - // validate vestingPercentage (0-100, u8) - if (vestingPercentage < 0 || vestingPercentage > 100) { - throw new Error('vestingPercentage must be between 0 and 100') - } - - // if vestingPercentage is 0, all other params should be 0 (zero vesting case) - if (vestingPercentage === 0) { - if ( - bpsPerPeriod !== 0 || - numberOfPeriods !== 0 || - cliffDurationFromMigrationTime !== 0 || - totalDuration !== 0 - ) { - throw new Error( - 'If vestingPercentage is 0, all other parameters must be 0' - ) - } - return { - vestingPercentage: 0, - bpsPerPeriod: 0, - numberOfPeriods: 0, - cliffDurationFromMigrationTime: 0, - frequency: 0, - } - } - - if (bpsPerPeriod < 0 || bpsPerPeriod > MAX_BASIS_POINT) { - throw new Error(`bpsPerPeriod must be between 0 and ${MAX_BASIS_POINT}`) - } +/** + * Get the quote amount a migration threshold deposits into the migrated pool + * @param migrationQuoteThreshold - The migration quote threshold in lamports + * @param migrationFeePercentage - The migration fee percentage + * @returns The migration quote amount in lamports, rounded up + */ +export function getMigrationQuoteAmountFromThreshold( + migrationQuoteThreshold: BN, + migrationFeePercentage: number +): BN { + return mulDiv( + migrationQuoteThreshold, + new BN(100 - migrationFeePercentage), + new BN(100), + Rounding.Up + ) +} - if (numberOfPeriods <= 0) { - throw new Error( - 'numberOfPeriods must be greater than zero when vestingPercentage > 0' +/** + * Get the quote amount that sizes a migration base amount at a sqrt price + * @param migrationBaseAmount - The migration base amount + * @param sqrtMigrationPrice - The migration sqrt price + * @param migrationOption - The migration option + * @param migratedCollectFeeMode - Migrated DAMM v2 collect fee mode. Compounding uses constant product. + * @returns The migration quote amount, rounded down + */ +export function getMigrationQuoteAmountFromMigrationBase( + migrationBaseAmount: BN, + sqrtMigrationPrice: BN, + migrationOption: MigrationOption, + migratedCollectFeeMode: MigratedCollectFeeMode = MigratedCollectFeeMode.QuoteToken +): BN { + if ( + migrationOption === MigrationOption.MET_DAMM || + migratedCollectFeeMode === MigratedCollectFeeMode.Compounding + ) { + return migrationBaseAmount + .mul(sqrtMigrationPrice) + .mul(sqrtMigrationPrice) + .shrn(128) + } else if (migrationOption === MigrationOption.MET_DAMM_V2) { + const liquidity = getInitialLiquidityFromDeltaBase( + migrationBaseAmount, + MAX_SQRT_PRICE, + sqrtMigrationPrice + ) + return getDeltaAmountQuoteUnsigned( + MIN_SQRT_PRICE, + sqrtMigrationPrice, + liquidity, + Rounding.Down ) + } else { + throw Error('Invalid migration option') } +} - if (cliffDurationFromMigrationTime < 0) { - throw new Error('cliffDurationFromMigrationTime must be >= 0') +/** + * Get the migration base amount per unit of migration quote at a sqrt price + * @param sqrtMigrationPrice - The migration sqrt price + * @param migrationOption - The migration option + * @param migratedCollectFeeMode - Migrated DAMM v2 collect fee mode. Compounding uses constant product. + * @returns The migration base weight, unrounded + */ +export function getMigrationBaseWeight( + sqrtMigrationPrice: BN, + migrationOption: MigrationOption, + migratedCollectFeeMode: MigratedCollectFeeMode +): Decimal { + const p = new Decimal(sqrtMigrationPrice.toString()) + if ( + migrationOption === MigrationOption.MET_DAMM || + migratedCollectFeeMode === MigratedCollectFeeMode.Compounding + ) { + return new Decimal(1).div(p.mul(p)) } + const maxSqrtPrice = new Decimal(MAX_SQRT_PRICE.toString()) + const minSqrtPrice = new Decimal(MIN_SQRT_PRICE.toString()) + return maxSqrtPrice.sub(p).div(p.sub(minSqrtPrice).mul(p).mul(maxSqrtPrice)) +} - if (totalDuration <= 0) { - throw new Error('totalDuration must be greater than zero') +function migrationDepositAmounts( + baseBudget: BN, + quoteBudget: BN, + baseAmount: BN, + quoteAmount: BN +): [BN, BN] { + if (baseAmount.eq(baseBudget) && quoteAmount.eq(quoteBudget)) { + return [baseBudget, quoteBudget] } - const frequency = totalDuration / numberOfPeriods + const baseSideBinds = baseAmount + .mul(quoteBudget) + .lte(quoteAmount.mul(baseBudget)) + const depositBase = baseSideBinds + ? baseAmount + : mulDiv(baseBudget, quoteAmount, quoteBudget, Rounding.Down) + const depositQuote = baseSideBinds + ? mulDiv(quoteBudget, baseAmount, baseBudget, Rounding.Down) + : quoteAmount - if (frequency <= 0) { - throw new Error( - 'frequency must be greater than zero (totalDuration / numberOfPeriods must be > 0)' - ) + if (depositBase.isZero() || depositQuote.isZero()) { + throw new Error('Amount is zero') } + return [depositBase, depositQuote] +} - const totalBps = bpsPerPeriod * numberOfPeriods - if (totalBps > MAX_BASIS_POINT) { +function compoundingInitialSqrtPriceAndLiquidity( + baseAmount: BN, + quoteAmount: BN +): { sqrtPrice: BN; liquidity: BN } { + if (baseAmount.isZero()) { + throw new Error('Math overflow') + } + const shiftedQuote = quoteAmount.shln(128) + const sqrtPrice = sqrt( + shiftedQuote.add(baseAmount).sub(new BN(1)).div(baseAmount) + ) + if (sqrtPrice.lt(MIN_SQRT_PRICE) || sqrtPrice.gt(MAX_SQRT_PRICE)) { throw new Error( - `Total BPS (bpsPerPeriod * numberOfPeriods = ${totalBps}) must not exceed ${MAX_BASIS_POINT}` + 'Invalid compounding parameters: the migration deposit sqrt price is out of range' ) } + const liquidity = sqrt(shiftedQuote.div(baseAmount)).mul(baseAmount) + return { sqrtPrice, liquidity } +} - const totalVestingDuration = - cliffDurationFromMigrationTime + numberOfPeriods * frequency - if (totalVestingDuration > MAX_LOCK_DURATION_IN_SECONDS) { - throw new Error( - `Total vesting duration (${totalVestingDuration}s) must not exceed ${MAX_LOCK_DURATION_IN_SECONDS}s (2 years)` - ) +/** + * Validate the compounding DAMM v2 deposit a config migrates with, after protocol and transfer fees. + * Throws when the pool would start below dead liquidity or more than 1% away from the migration price. + */ +export function validateCompoundingMigrationDeposit(params: { + migrationQuoteThreshold: BN + migrationFeePercentage: number + migrationSqrtPrice: BN + baseTransferFee?: EpochTransferFee | null + quoteTransferFee?: EpochTransferFee | null +}): void { + const quoteAmount = getMigrationQuoteAmountFromThreshold( + params.migrationQuoteThreshold, + params.migrationFeePercentage + ) + const baseAmount = constantProductBaseFromQuote( + quoteAmount, + params.migrationSqrtPrice + ) + const [protocolBaseFee, protocolQuoteFee] = getProtocolMigrationFee( + baseAmount, + quoteAmount, + params.migrationSqrtPrice, + PROTOCOL_LIQUIDITY_MIGRATION_FEE_BPS, + MigrationOption.MET_DAMM_V2, + MigratedCollectFeeMode.Compounding + ) + const excludedProtocolBase = baseAmount.sub(protocolBaseFee) + const excludedProtocolQuote = quoteAmount.sub(protocolQuoteFee) + const excludedTransferBase = calculateTransferFeeExcludedAmount( + params.baseTransferFee ?? null, + excludedProtocolBase + ).amount + const excludedTransferQuote = calculateTransferFeeExcludedAmount( + params.quoteTransferFee ?? null, + excludedProtocolQuote + ).amount + const [depositBase, depositQuote] = migrationDepositAmounts( + excludedProtocolBase, + excludedProtocolQuote, + excludedTransferBase, + excludedTransferQuote + ) + const { sqrtPrice, liquidity } = compoundingInitialSqrtPriceAndLiquidity( + depositBase, + depositQuote + ) + if (liquidity.lte(DAMM_V2_COMPOUNDING_DEAD_LIQUIDITY)) { + throw new Error('Insufficient liquidity for migration') } - - if (cliffDurationFromMigrationTime === 0 && numberOfPeriods === 0) { + const priceGap = params.migrationSqrtPrice.sub(sqrtPrice).abs() + const largerPrice = BN.max(params.migrationSqrtPrice, sqrtPrice) + if (priceGap.mul(new BN(100)).gt(largerPrice)) { throw new Error( - 'If cliffDurationFromMigrationTime is 0, numberOfPeriods must be > 0' + 'Invalid curve: the compounding migration deposit is more than 1% away from the migration price' ) } +} - return { - vestingPercentage, - bpsPerPeriod, - numberOfPeriods, - cliffDurationFromMigrationTime, - frequency: Math.round(frequency), - } +/** + * Get the liquidity + * @param baseAmount - The base amount + * @param quoteAmount - The quote amount + * @param minSqrtPrice - The min sqrt price + * @param maxSqrtPrice - The max sqrt price + * @returns The liquidity + */ +export const getLiquidity = ( + baseAmount: BN, + quoteAmount: BN, + minSqrtPrice: BN, + maxSqrtPrice: BN +): BN => { + const liquidityFromBase = getInitialLiquidityFromDeltaBase( + baseAmount, + maxSqrtPrice, + minSqrtPrice + ) + const liquidityFromQuote = getInitialLiquidityFromDeltaQuote( + quoteAmount, + minSqrtPrice, + maxSqrtPrice + ) + return BN.min(liquidityFromBase, liquidityFromQuote) } /** - * Get the two curve - * @param migrationSqrPrice - The migration sqrt price - * @param initialSqrtPrice - The initial sqrt price + * Get the first curve + * @param migrationSqrtPrice - The migration sqrt price * @param swapAmount - The swap amount * @param migrationQuoteThreshold - The migration quote threshold - * @returns The two curve + * @returns The first curve */ -export const getTwoCurve = ( +export const getFirstCurve = ( migrationSqrtPrice: BN, - midSqrtPrice: BN, - initialSqrtPrice: BN, swapAmount: BN, migrationQuoteThreshold: BN ) => { - const p0 = new Decimal(initialSqrtPrice.toString()) - const p1 = new Decimal(midSqrtPrice.toString()) - const p2 = new Decimal(migrationSqrtPrice.toString()) - - const a1 = new Decimal(1).div(p0).sub(new Decimal(1).div(p1)) - const b1 = new Decimal(1).div(p1).sub(new Decimal(1).div(p2)) - const c1 = new Decimal(swapAmount.toString()) + // Swap_amount = L * (Pmax - Pmin) / (Pmax * Pmin) (1) + // Quote_amount = L * (Pmax - Pmin) / 2^128 (2) + // From (1) and (2) => Pmin = Quote_amount * 2^128 / (Swap_amount * Pmax) + const sqrtStartPrice = migrationQuoteThreshold + .shln(128) + .div(swapAmount.mul(migrationSqrtPrice)) - const a2 = p1.sub(p0) - const b2 = p2.sub(p1) - const c2 = new Decimal(migrationQuoteThreshold.toString()).mul( - Decimal.pow(2, 128) + const liquidity = getLiquidity( + swapAmount, + migrationQuoteThreshold, + sqrtStartPrice, + migrationSqrtPrice ) - - // solve equation to find l0 and l1 - const l0 = c1 - .mul(b2) - .sub(c2.mul(b1)) - .div(a1.mul(b2).sub(a2.mul(b1))) - const l1 = c1 - .mul(a2) - .sub(c2.mul(a1)) - .div(b1.mul(a2).sub(b2.mul(a1))) - - if (l0.isNeg() || l1.isNeg()) { - return { - isOk: false, - sqrtStartPrice: new BN(0), - curve: [], - } - } - return { - isOk: true, - sqrtStartPrice: initialSqrtPrice, + sqrtStartPrice, curve: [ - { - sqrtPrice: midSqrtPrice, - liquidity: new BN(l0.floor().toFixed()), - }, { sqrtPrice: migrationSqrtPrice, - liquidity: new BN(l1.floor().toFixed()), + liquidity, }, ], } } /** - * Check if rate limiter should be applied based on pool configuration and state - * @param baseFeeMode - The base fee mode - * @param swapBaseForQuote - Whether the swap is from base to quote - * @param currentPoint - The current point - * @param activationPoint - The activation point - * @param maxLimiterDuration - The maximum limiter duration - * @returns Whether rate limiter should be applied + * Get the total supply from curve + * @param migrationQuoteThreshold - The migration quote threshold + * @param sqrtStartPrice - The start sqrt price + * @param curve - The curve + * @param lockedVesting - The locked vesting + * @param migrationOption - The migration option + * @param leftover - The leftover + * @param migrationFeePercent - The migration fee percent + * @param migratedCollectFeeMode - Migrated DAMM v2 collect fee mode + * @returns The total supply */ -export function checkRateLimiterApplied( - baseFeeMode: BaseFeeMode, - swapBaseForQuote: boolean, - currentPoint: BN, - activationPoint: BN, - maxLimiterDuration: BN -): boolean { - return ( - baseFeeMode === BaseFeeMode.RateLimiter && - !swapBaseForQuote && - currentPoint.gte(activationPoint) && - currentPoint.lte(activationPoint.add(maxLimiterDuration)) +export const getTotalSupplyFromCurve = ( + migrationQuoteThreshold: BN, + sqrtStartPrice: BN, + curve: Array, + lockedVesting: LockedVestingParameters, + migrationOption: MigrationOption, + leftover: BN, + migrationFeePercent: number, + migratedCollectFeeMode: MigratedCollectFeeMode = MigratedCollectFeeMode.QuoteToken +): BN => { + const sqrtMigrationPrice = getMigrationThresholdPrice( + migrationQuoteThreshold, + sqrtStartPrice, + curve + ) + const swapBaseAmount = getBaseTokenForSwap( + sqrtStartPrice, + sqrtMigrationPrice, + curve + ) + const swapBaseAmountBuffer = getSwapAmountWithBuffer( + swapBaseAmount, + sqrtStartPrice, + curve + ) + + const migrationQuoteAmount = getMigrationQuoteAmountFromThreshold( + migrationQuoteThreshold, + migrationFeePercent + ) + const migrationBaseAmount = getMigrationBaseToken( + migrationQuoteAmount, + sqrtMigrationPrice, + migrationOption, + migratedCollectFeeMode ) + const totalVestingAmount = getTotalVestingAmount(lockedVesting) + const minimumBaseSupplyWithBuffer = swapBaseAmountBuffer + .add(migrationBaseAmount) + .add(totalVestingAmount) + .add(leftover) + return minimumBaseSupplyWithBuffer } /** - * Get base fee parameters based on the base fee mode - * @param baseFeeParams - The base fee parameters - * @returns The base fee parameters + * Get the migration threshold price + * @param migrationThreshold - The migration threshold + * @param sqrtStartPrice - The start sqrt price + * @param curve - The curve + * @returns The migration threshold price */ -export function getBaseFeeParams(baseFeeParams: BaseFeeParams): BaseFee { - if (baseFeeParams.baseFeeMode === BaseFeeMode.RateLimiter) { - throw new Error( - 'BaseFeeMode.RateLimiter is deprecated. New configs must use FeeSchedulerLinear or FeeSchedulerExponential.' - ) - } +export const getMigrationThresholdPrice = ( + migrationThreshold: BN, + sqrtStartPrice: BN, + curve: Array +): BN => { + let nextSqrtPrice = sqrtStartPrice - const { startingFeeBps, endingFeeBps, numberOfPeriod, totalDuration } = - baseFeeParams.feeSchedulerParam + if (curve.length === 0) { + throw Error('Curve is empty') + } - return getFeeSchedulerParams( - startingFeeBps, - endingFeeBps, - baseFeeParams.baseFeeMode, - numberOfPeriod, - totalDuration + const totalAmount = getDeltaAmountQuoteUnsigned( + nextSqrtPrice, + curve[0].sqrtPrice, + curve[0].liquidity, + Rounding.Up ) + if (totalAmount.gt(migrationThreshold)) { + nextSqrtPrice = getNextSqrtPriceFromInput( + nextSqrtPrice, + curve[0].liquidity, + migrationThreshold, + false + ) + } else { + let amountLeft = migrationThreshold.sub(totalAmount) + nextSqrtPrice = curve[0].sqrtPrice + for (let i = 1; i < curve.length; i++) { + const maxAmount = getDeltaAmountQuoteUnsigned( + nextSqrtPrice, + curve[i].sqrtPrice, + curve[i].liquidity, + Rounding.Up + ) + if (maxAmount.gt(amountLeft)) { + nextSqrtPrice = getNextSqrtPriceFromInput( + nextSqrtPrice, + curve[i].liquidity, + amountLeft, + false + ) + amountLeft = new BN(0) + break + } else { + amountLeft = amountLeft.sub(maxAmount) + nextSqrtPrice = curve[i].sqrtPrice + } + } + if (!amountLeft.isZero()) { + const migrationThresholdStr = migrationThreshold.toString() + const amountLeftStr = amountLeft.toString() + throw Error( + `Not enough liquidity, migrationThreshold: ${migrationThresholdStr} amountLeft: ${amountLeftStr}` + ) + } + } + return nextSqrtPrice } /** - * Get the quote token amount from sqrt price - * @param nextSqrtPrice - The next sqrt price - * @param config - The pool configuration - * @returns The total quote token amount + * Calculate the quote amount allocated to each curve segment + * Formula: Δb = L * (√P_upper - √P_lower) for each segment + * @param migrationQuoteThreshold - The total migration quote threshold + * @param sqrtStartPrice - The start sqrt price + * @param curve - The curve segments with sqrtPrice and liquidity + * @returns Array of quote amounts for each segment and the final sqrt price reached */ -export function getQuoteReserveFromNextSqrtPrice( - nextSqrtPrice: BN, - config: PoolConfig -): BN { - let totalAmount = new BN(0) +export const getCurveBreakdown = ( + migrationQuoteThreshold: BN, + sqrtStartPrice: BN, + curve: Array +): { + segmentAmounts: BN[] + finalSqrtPrice: BN + totalAmount: BN +} => { + if (curve.length === 0) { + throw Error('Curve is empty') + } - for (let i = 0; i < config.curve.length; i++) { - const lowerSqrtPrice = - i === 0 ? config.sqrtStartPrice : config.curve[i - 1].sqrtPrice + const segmentAmounts: BN[] = [] + let totalAllocated = new BN(0) + let currentSqrtPrice = sqrtStartPrice + let finalSqrtPrice = sqrtStartPrice - if (nextSqrtPrice.gt(lowerSqrtPrice)) { - const upperSqrtPrice = nextSqrtPrice.lt(config.curve[i].sqrtPrice) - ? nextSqrtPrice - : config.curve[i].sqrtPrice + for (let i = 0; i < curve.length; i++) { + const lowerSqrtPrice = currentSqrtPrice + const upperSqrtPrice = curve[i].sqrtPrice + const liquidity = curve[i].liquidity + + // calculate max quote amount available in this segment + // formula: Δb = L * (√P_upper - √P_lower) + const maxSegmentAmount = getDeltaAmountQuoteUnsigned( + lowerSqrtPrice, + upperSqrtPrice, + liquidity, + Rounding.Up + ) - const maxAmountIn = getDeltaAmountQuoteUnsigned( + if (maxSegmentAmount.gte(migrationQuoteThreshold)) { + // this segment contains the migration point + segmentAmounts.push(migrationQuoteThreshold) + totalAllocated = totalAllocated.add(migrationQuoteThreshold) + + // calculate the exact sqrt price where migration threshold is reached + finalSqrtPrice = getNextSqrtPriceFromInput( lowerSqrtPrice, - upperSqrtPrice, - config.curve[i].liquidity, - Rounding.Up + liquidity, + migrationQuoteThreshold, + false ) - totalAmount = totalAmount.add(maxAmountIn) + // fill remaining segments with zero + for (let j = i + 1; j < curve.length; j++) { + segmentAmounts.push(new BN(0)) + } + break + } else { + // this segment is fully consumed + segmentAmounts.push(maxSegmentAmount) + totalAllocated = totalAllocated.add(maxSegmentAmount) + currentSqrtPrice = upperSqrtPrice + finalSqrtPrice = upperSqrtPrice + + // check if we've processed all segments but haven't reached threshold + if ( + i === curve.length - 1 && + totalAllocated.lt(migrationQuoteThreshold) + ) { + const shortfall = migrationQuoteThreshold.sub(totalAllocated) + throw Error( + `Not enough liquidity in curve. Total allocated: ${totalAllocated.toString()}, Required: ${migrationQuoteThreshold.toString()}, Shortfall: ${shortfall.toString()}` + ) + } } } - return totalAmount + return { + segmentAmounts, + finalSqrtPrice, + totalAmount: totalAllocated, + } } /** - * Get the tokenomics + * Get the swap amount with buffer + * @param swapBaseAmount - The swap base amount + * @param sqrtStartPrice - The start sqrt price + * @param curve - The curve + * @returns The swap amount with buffer + */ +export const getSwapAmountWithBuffer = ( + swapBaseAmount: BN, + sqrtStartPrice: BN, + curve: Array +): BN => { + const swapAmountBuffer = swapBaseAmount.add( + swapBaseAmount.mul(new BN(SWAP_BUFFER_PERCENTAGE)).div(new BN(100)) + ) + const maxBaseAmountOnCurve = getBaseTokenForSwap( + sqrtStartPrice, + MAX_SQRT_PRICE, + curve + ) + return BN.min(swapAmountBuffer, maxBaseAmountOnCurve) +} + +/** + * Get the percentage of supply that should be allocated to initial liquidity * @param initialMarketCap - The initial market cap * @param migrationMarketCap - The migration market cap - * @param totalLockedVestingAmount - The total locked vesting amount - * @param totalLeftover - The total leftover + * @param lockedVesting - The locked vesting + * @param totalLeftover - The leftover * @param totalTokenSupply - The total token supply - * @returns The tokenomics + * @returns The percentage of supply for initial liquidity */ -export const getTokenomics = ( +export const getPercentageSupplyOnMigration = ( initialMarketCap: Decimal, migrationMarketCap: Decimal, - totalLockedVestingAmount: BN, + lockedVesting: LockedVestingParameters, totalLeftover: BN, totalTokenSupply: BN -): { - bondingCurveSupply: BN - migrationSupply: BN - leftoverSupply: BN - lockedVestingSupply: BN -} => { +): number => { // formula: x = sqrt(initialMC / migrationMC) * (100 - lockedVesting - leftover) / (1 + sqrt(initialMC / migrationMC)) // sqrtRatio = sqrt(initial_MC / migration_MC) @@ -1643,7 +1995,8 @@ export const getTokenomics = ( const sqrtRatio = Decimal.sqrt(marketCapRatio) // locked vesting percentage - const vestingPercentage = new Decimal(totalLockedVestingAmount.toString()) + const totalVestingAmount = getTotalVestingAmount(lockedVesting) + const vestingPercentage = new Decimal(totalVestingAmount.toString()) .mul(new Decimal(100)) .div(new Decimal(totalTokenSupply.toString())) @@ -1653,300 +2006,200 @@ export const getTokenomics = ( .div(new Decimal(totalTokenSupply.toString())) // (100 * sqrtRatio - (vestingPercentage + leftoverPercentage) * sqrtRatio) / (1 + sqrtRatio) - const percentageSupplyOnMigration = new Decimal(100) + const numerator = new Decimal(100) .mul(sqrtRatio) .sub(vestingPercentage.add(leftoverPercentage).mul(sqrtRatio)) const denominator = new Decimal(1).add(sqrtRatio) - - // Calculate migration supply as BN - const migrationSupplyDecimal = percentageSupplyOnMigration - .div(denominator) - .mul(new Decimal(totalTokenSupply.toString())) - .div(new Decimal(100)) - const migrationSupply = new BN(migrationSupplyDecimal.floor().toFixed()) - - // Calculate bonding curve supply (remaining after subtracting known amounts) - const bondingCurveSupply = totalTokenSupply - .sub(migrationSupply) - .sub(totalLeftover) - .sub(totalLockedVestingAmount) - - return { - bondingCurveSupply: bondingCurveSupply, - migrationSupply: migrationSupply, - leftoverSupply: totalLeftover, - lockedVestingSupply: totalLockedVestingAmount, - } + return numerator.div(denominator).toNumber() } /** - * Get migrated pool fee parameters based on migration options - * @param migrationOption - The migration option (DAMM or DAMM_V2) - * @param migrationFeeOption - The fee option (fixed rates 0-5 or customizable) - * @param migratedPoolFee - Optional custom migrated pool fee parameters (only used with DAMM_V2 + Customizable) - * @returns Migrated pool fee parameters with appropriate defaults + * Calculate the adjusted percentageSupplyOnMigration that accounts for migrationFee + * + * Formula: + * - D = desiredMarketCap + * - M = migrationMarketCap + * - f = migrationFee + * - V = vesting percentage + * - L = leftover percentage + * + * requiredRatio = sqrt(D / M) + * percentageSupplyOnMigration = (requiredRatio * (1 - f) * (100 - V - L)) / (1 + requiredRatio * (1 - f)) */ -export function getMigratedPoolFeeParams( - migrationOption: MigrationOption, - migrationFeeOption: MigrationFeeOption, - migratedPoolFee?: MigratedPoolFeeConfig, - baseFeeParams?: BaseFeeParams -): MigratedPoolFeeResult { - const defaultResult: MigratedPoolFeeResult = { - migratedPoolFee: DEFAULT_MIGRATED_POOL_FEE_PARAMS, - migratedPoolBaseFeeMode: DammV2BaseFeeMode.FeeTimeSchedulerLinear, - migratedPoolMarketCapFeeSchedulerParams: - DEFAULT_MIGRATED_POOL_MARKET_CAP_FEE_SCHEDULER_PARAMS, - migrationFeeOption, - compoundingFeeBps: 0, - } - - if (migrationOption === MigrationOption.MET_DAMM) { - throw new Error( - 'MigrationOption.MET_DAMM (DAMM v1) is deprecated. New configs must use MigrationOption.MET_DAMM_V2.' - ) - } - - // for DAMM_V2: use custom parameters based on configuration - if (migrationOption === MigrationOption.MET_DAMM_V2) { - const baseFeeMode = - migratedPoolFee?.baseFeeMode ?? - DammV2BaseFeeMode.FeeTimeSchedulerLinear - - // when marketCapFeeSchedulerParams is configured, use custom values - if (migratedPoolFee?.marketCapFeeSchedulerParams && baseFeeParams) { - const schedulerParams = getMigratedPoolMarketCapFeeSchedulerParams( - migratedPoolFee.poolFeeBps, - migratedPoolFee.marketCapFeeSchedulerParams.endingBaseFeeBps, - baseFeeMode, - migratedPoolFee.marketCapFeeSchedulerParams.numberOfPeriod, - migratedPoolFee.marketCapFeeSchedulerParams.priceMultiple, - migratedPoolFee.marketCapFeeSchedulerParams - .schedulerExpirationDuration - ) - - return { - migratedPoolFee: { - collectFeeMode: migratedPoolFee.collectFeeMode, - dynamicFee: migratedPoolFee.dynamicFee, - poolFeeBps: migratedPoolFee.poolFeeBps, - }, - migratedPoolBaseFeeMode: baseFeeMode, - migratedPoolMarketCapFeeSchedulerParams: schedulerParams, - migrationFeeOption: MigrationFeeOption.Customizable, - compoundingFeeBps: migratedPoolFee.compoundingFeeBps ?? 0, - } - } - - // use custom parameters if Customizable option is selected - if (migrationFeeOption === MigrationFeeOption.Customizable) { - return { - migratedPoolFee: { - collectFeeMode: - migratedPoolFee?.collectFeeMode ?? - DEFAULT_MIGRATED_POOL_FEE_PARAMS.collectFeeMode, - dynamicFee: - migratedPoolFee?.dynamicFee ?? - DEFAULT_MIGRATED_POOL_FEE_PARAMS.dynamicFee, - poolFeeBps: - migratedPoolFee?.poolFeeBps ?? - DEFAULT_MIGRATED_POOL_FEE_PARAMS.poolFeeBps, - }, - migratedPoolBaseFeeMode: baseFeeMode, - migratedPoolMarketCapFeeSchedulerParams: - DEFAULT_MIGRATED_POOL_MARKET_CAP_FEE_SCHEDULER_PARAMS, - migrationFeeOption: MigrationFeeOption.Customizable, - compoundingFeeBps: migratedPoolFee?.compoundingFeeBps ?? 0, - } - } - - // for fixed fee options (0-5), use defaults but preserve baseFeeMode if provided - return { - migratedPoolFee: DEFAULT_MIGRATED_POOL_FEE_PARAMS, - migratedPoolBaseFeeMode: baseFeeMode, - migratedPoolMarketCapFeeSchedulerParams: - DEFAULT_MIGRATED_POOL_MARKET_CAP_FEE_SCHEDULER_PARAMS, - migrationFeeOption, - compoundingFeeBps: 0, - } - } +export function calculateAdjustedPercentageSupplyOnMigration( + initialMarketCap: number, + migrationMarketCap: number, + migrationFee: { feePercentage: number }, + lockedVesting: LockedVestingParameters, + totalLeftover: BN, + totalTokenSupply: BN +): number { + const D = new Decimal(initialMarketCap) + const M = new Decimal(migrationMarketCap) + const f = new Decimal(migrationFee.feePercentage).div(100) - return defaultResult -} + // calculate vesting and leftover percentages + const totalVestingAmount = getTotalVestingAmount(lockedVesting) + const V = new Decimal(totalVestingAmount.toString()) + .mul(100) + .div(new Decimal(totalTokenSupply.toString())) + const L = new Decimal(totalLeftover.toString()) + .mul(100) + .div(new Decimal(totalTokenSupply.toString())) -/** - * Get the current point based on activation type - * @param connection - The Solana connection instance - * @param activationType - The activation type (Slot or Time) - * @returns The current point as a BN - */ -export async function getCurrentPoint( - connection: Connection, - activationType: ActivationType -): Promise { - const currentSlot = await connection.getSlot() + // requiredRatio = sqrt(D / M) + const requiredRatio = Decimal.sqrt(D.div(M)) - if (activationType === ActivationType.Slot) { - return new BN(currentSlot) - } else { - const currentTime = await connection.getBlockTime(currentSlot) - return new BN(currentTime) - } + // percentageSupplyOnMigration = (requiredRatio * (1 - f) * (100 - V - L)) / (1 + requiredRatio * (1 - f)) + const oneMinusF = new Decimal(1).sub(f) + const availablePercentage = new Decimal(100).sub(V).sub(L) + const numerator = requiredRatio.mul(oneMinusF).mul(availablePercentage) + const denominator = new Decimal(1).add(requiredRatio.mul(oneMinusF)) + const percentageSupplyOnMigration = numerator.div(denominator).toNumber() + + return percentageSupplyOnMigration } /** - * Prepare the swap amount param - * @param amount - The amount to swap - * @param mintAddress - The mint address - * @param connection - The Solana connection instance - * @returns The amount in lamports + * Get the migration quote amount + * @param migrationMarketCap - The migration market cap + * @param percentageSupplyOnMigration - The percentage of supply on migration + * @returns The migration quote amount */ -export async function prepareSwapAmountParam( - amount: number, - mintAddress: PublicKey, - connection: Connection -): Promise { - const mintTokenDecimals = await getTokenDecimals(connection, mintAddress) - - return convertToLamports(amount, mintTokenDecimals) +export const getMigrationQuoteAmount = ( + migrationMarketCap: Decimal, + percentageSupplyOnMigration: Decimal +): Decimal => { + // migrationMC * x / 100 + return migrationMarketCap + .mul(percentageSupplyOnMigration) + .div(new Decimal(100)) } /** - * Calculate the locked liquidity BPS for a single vesting info at a given time. - * @param vestingInfo - The liquidity vesting info parameters - * @param nSeconds - Number of seconds after migration - * @returns The locked liquidity in BPS (basis points) + * Get the two curve + * @param migrationSqrtPrice - The migration sqrt price + * @param midSqrtPrice - The mid sqrt price + * @param initialSqrtPrice - The initial sqrt price + * @param swapAmount - The swap amount + * @param migrationQuoteThreshold - The migration quote threshold + * @returns The two curve */ -export function getVestingLockedLiquidityBpsAtNSeconds( - vestingInfo: LiquidityVestingInfoParameters | undefined, - nSeconds: number -): number { - // If no vesting info or vesting percentage is 0, return 0 - if (!vestingInfo || vestingInfo.vestingPercentage === 0) { - return 0 - } - - const totalLiquidity = U128_MAX - - // total_vested_liquidity = floor(total_liquidity * vesting_percentage / 100) - const totalVestedLiquidity = totalLiquidity - .mul(new BN(vestingInfo.vestingPercentage)) - .div(new BN(100)) - - const bpsPerPeriod = vestingInfo.bpsPerPeriod - const numberOfPeriods = vestingInfo.numberOfPeriods - const frequency = vestingInfo.frequency - const cliffDuration = vestingInfo.cliffDurationFromMigrationTime - - // calculate total BPS that will be unlocked over all periods - const totalBpsAfterCliff = bpsPerPeriod * numberOfPeriods - - // total_vesting_liquidity_after_cliff = floor(total_vested_liquidity * total_bps_after_cliff / MAX_BASIS_POINT) - const totalVestingLiquidityAfterCliff = totalVestedLiquidity - .mul(new BN(totalBpsAfterCliff)) - .div(new BN(MAX_BASIS_POINT)) - - // liquidity_per_period = floor(total_vesting_liquidity_after_cliff / number_of_periods) - let liquidityPerPeriod = new BN(0) - let adjustedFrequency = frequency - let adjustedNumberOfPeriods = numberOfPeriods - let adjustedCliffDuration = cliffDuration - - if (numberOfPeriods > 0) { - liquidityPerPeriod = totalVestingLiquidityAfterCliff.div( - new BN(numberOfPeriods) - ) - } +export const getTwoCurve = ( + migrationSqrtPrice: BN, + midSqrtPrice: BN, + initialSqrtPrice: BN, + swapAmount: BN, + migrationQuoteThreshold: BN +) => { + const p0 = new Decimal(initialSqrtPrice.toString()) + const p1 = new Decimal(midSqrtPrice.toString()) + const p2 = new Decimal(migrationSqrtPrice.toString()) - // If liquidity_per_period == 0 (due to precision loss), make it cliff-only lock - if (liquidityPerPeriod.isZero()) { - adjustedNumberOfPeriods = 0 - adjustedFrequency = 0 - adjustedCliffDuration = Math.max(cliffDuration, 1) - } + const a1 = new Decimal(1).div(p0).sub(new Decimal(1).div(p1)) + const b1 = new Decimal(1).div(p1).sub(new Decimal(1).div(p2)) + const c1 = new Decimal(swapAmount.toString()) - // cliff_unlock_liquidity = total_vested_liquidity - (liquidity_per_period * number_of_periods) - const cliffUnlockLiquidity = totalVestedLiquidity.sub( - liquidityPerPeriod.mul(new BN(adjustedNumberOfPeriods)) + const a2 = p1.sub(p0) + const b2 = p2.sub(p1) + const c2 = new Decimal(migrationQuoteThreshold.toString()).mul( + Decimal.pow(2, 128) ) - // calculate unlocked liquidity at nSeconds using vesting parameters - // cliff_point = current_timestamp (0) + cliff_duration - const cliffPoint = new BN(adjustedCliffDuration) - const currentPoint = new BN(nSeconds) - - let unlockedLiquidity = new BN(0) - - if (currentPoint.gte(cliffPoint)) { - // past cliff - add cliff unlock amount - unlockedLiquidity = cliffUnlockLiquidity + // solve equation to find l0 and l1 + const l0 = c1 + .mul(b2) + .sub(c2.mul(b1)) + .div(a1.mul(b2).sub(a2.mul(b1))) + const l1 = c1 + .mul(a2) + .sub(c2.mul(a1)) + .div(b1.mul(a2).sub(b2.mul(a1))) - // calculate periods elapsed after cliff - if (adjustedFrequency > 0 && adjustedNumberOfPeriods > 0) { - const timeAfterCliff = currentPoint.sub(cliffPoint) - const periodsElapsed = timeAfterCliff - .div(new BN(adjustedFrequency)) - .toNumber() - const actualPeriodsElapsed = Math.min( - periodsElapsed, - adjustedNumberOfPeriods - ) - unlockedLiquidity = unlockedLiquidity.add( - liquidityPerPeriod.mul(new BN(actualPeriodsElapsed)) - ) + if (l0.isNeg() || l1.isNeg()) { + return { + isOk: false, + sqrtStartPrice: new BN(0), + curve: [], } } - // locked_liquidity = total_vested_liquidity - unlocked_liquidity - const lockedLiquidity = totalVestedLiquidity.sub(unlockedLiquidity) - - // liquidity_locked_bps = floor(locked_liquidity * MAX_BASIS_POINT / total_liquidity) - const liquidityLockedBps = lockedLiquidity - .mul(new BN(MAX_BASIS_POINT)) - .div(totalLiquidity) - - return liquidityLockedBps.toNumber() + return { + isOk: true, + sqrtStartPrice: initialSqrtPrice, + curve: [ + { + sqrtPrice: midSqrtPrice, + liquidity: new BN(l0.floor().toFixed()), + }, + { + sqrtPrice: migrationSqrtPrice, + liquidity: new BN(l1.floor().toFixed()), + }, + ], + } } /** - * Calculate the locked liquidity BPS at a given time (in seconds) after migration - * @param partnerPermanentLockedLiquidityPercentage - Partner's permanently locked liquidity percentage - * @param creatorPermanentLockedLiquidityPercentage - Creator's permanently locked liquidity percentage - * @param partnerLiquidityVestingInfo - Partner's liquidity vesting info (optional) - * @param creatorLiquidityVestingInfo - Creator's liquidity vesting info (optional) - * @param elapsedSeconds - Number of seconds after migration - * @returns The total locked liquidity in BPS (basis points) + * Get the tokenomics + * @param initialMarketCap - The initial market cap + * @param migrationMarketCap - The migration market cap + * @param totalLockedVestingAmount - The total locked vesting amount + * @param totalLeftover - The total leftover + * @param totalTokenSupply - The total token supply + * @returns The tokenomics */ -export function calculateLockedLiquidityBpsAtTime( - partnerPermanentLockedLiquidityPercentage: number, - creatorPermanentLockedLiquidityPercentage: number, - partnerLiquidityVestingInfo: LiquidityVestingInfoParameters | undefined, - creatorLiquidityVestingInfo: LiquidityVestingInfoParameters | undefined, - elapsedSeconds: number -): number { - // calculate vested locked BPS using the same u128 arithmetic as on-chain - const partnerVestedLockedLiquidityBps = - getVestingLockedLiquidityBpsAtNSeconds( - partnerLiquidityVestingInfo, - elapsedSeconds - ) - const creatorVestedLockedLiquidityBps = - getVestingLockedLiquidityBpsAtNSeconds( - creatorLiquidityVestingInfo, - elapsedSeconds - ) +export const getTokenomics = ( + initialMarketCap: Decimal, + migrationMarketCap: Decimal, + totalLockedVestingAmount: BN, + totalLeftover: BN, + totalTokenSupply: BN +): { + bondingCurveSupply: BN + migrationSupply: BN + leftoverSupply: BN + lockedVestingSupply: BN +} => { + // formula: x = sqrt(initialMC / migrationMC) * (100 - lockedVesting - leftover) / (1 + sqrt(initialMC / migrationMC)) - const partnerPermanentLockedLiquidityBps = - partnerPermanentLockedLiquidityPercentage * 100 - const creatorPermanentLockedLiquidityBps = - creatorPermanentLockedLiquidityPercentage * 100 + // sqrtRatio = sqrt(initial_MC / migration_MC) + const marketCapRatio = initialMarketCap.div(migrationMarketCap) + const sqrtRatio = Decimal.sqrt(marketCapRatio) - // total locked = partner_vested + partner_permanent + creator_vested + creator_permanent - const totalLockedLiquidityBpsAtNSeconds = - partnerVestedLockedLiquidityBps + - partnerPermanentLockedLiquidityBps + - creatorVestedLockedLiquidityBps + - creatorPermanentLockedLiquidityBps + // locked vesting percentage + const vestingPercentage = new Decimal(totalLockedVestingAmount.toString()) + .mul(new Decimal(100)) + .div(new Decimal(totalTokenSupply.toString())) - return totalLockedLiquidityBpsAtNSeconds + // leftover percentage + const leftoverPercentage = new Decimal(totalLeftover.toString()) + .mul(new Decimal(100)) + .div(new Decimal(totalTokenSupply.toString())) + + // (100 * sqrtRatio - (vestingPercentage + leftoverPercentage) * sqrtRatio) / (1 + sqrtRatio) + const percentageSupplyOnMigration = new Decimal(100) + .mul(sqrtRatio) + .sub(vestingPercentage.add(leftoverPercentage).mul(sqrtRatio)) + const denominator = new Decimal(1).add(sqrtRatio) + + // Calculate migration supply as BN + const migrationSupplyDecimal = percentageSupplyOnMigration + .div(denominator) + .mul(new Decimal(totalTokenSupply.toString())) + .div(new Decimal(100)) + const migrationSupply = new BN(migrationSupplyDecimal.floor().toFixed()) + + // Calculate bonding curve supply (remaining after subtracting known amounts) + const bondingCurveSupply = totalTokenSupply + .sub(migrationSupply) + .sub(totalLeftover) + .sub(totalLockedVestingAmount) + + return { + bondingCurveSupply: bondingCurveSupply, + migrationSupply: migrationSupply, + leftoverSupply: totalLeftover, + lockedVestingSupply: totalLockedVestingAmount, + } } diff --git a/packages/dynamic-bonding-curve/src/helpers/index.ts b/packages/dynamic-bonding-curve/src/helpers/index.ts index dac117de..e1c8bc2e 100644 --- a/packages/dynamic-bonding-curve/src/helpers/index.ts +++ b/packages/dynamic-bonding-curve/src/helpers/index.ts @@ -6,3 +6,4 @@ export * from './buildCurve' export * from './createProgram' export * from './token' export * from './instructions' +export * from './quoteSwap' diff --git a/packages/dynamic-bonding-curve/src/helpers/quoteSwap.ts b/packages/dynamic-bonding-curve/src/helpers/quoteSwap.ts new file mode 100644 index 00000000..6dc103ae --- /dev/null +++ b/packages/dynamic-bonding-curve/src/helpers/quoteSwap.ts @@ -0,0 +1,173 @@ +import BN from 'bn.js' +import { + swapQuoteExactIn, + swapQuoteExactOut, + swapQuotePartialFill, +} from '../math/swapQuote' +import { getMigrationThresholdPrice } from './common' +import { + SwapMode, + type ConfigParameters, + type PoolConfig, + type QuoteTransferFees, + type SimulatedQuoteBaseParams, + type SimulatedQuoteFromInputAmountParams, + type SimulatedQuoteFromOutputAmountParams, + type SwapQuote2Result, + type VirtualPool, +} from '../types' + +function migrationSqrtPrice(config: ConfigParameters): BN { + if (!config.curve || config.curve.length === 0) { + throw new Error('config.curve is empty') + } + + return getMigrationThresholdPrice( + config.migrationQuoteThreshold, + config.sqrtStartPrice, + config.curve + ) +} + +/** + * `process_create_config` sets `initialized` to 1 when dynamic-fee parameters + * are present and leaves the default (0) when they are not. + */ +function dynamicFeeFromParameters( + dynamicFee: ConfigParameters['poolFees']['dynamicFee'] +) { + if (!dynamicFee) { + return { initialized: 0, binStep: 0, variableFeeControl: 0 } + } + + return { + ...dynamicFee, + initialized: 1, + } +} + +/** + * Fields `process_create_config` writes that the quote math reads. + * The quote functions are typed on the account, so this is asserted at the + * boundary. Padding and pubkeys the math does not read are left unset. + */ +function poolConfigFromParameters( + config: ConfigParameters, + quoteTokenFlag: number | undefined +): PoolConfig { + return { + poolFees: { + baseFee: config.poolFees.baseFee, + dynamicFee: dynamicFeeFromParameters(config.poolFees.dynamicFee), + }, + collectFeeMode: config.collectFeeMode, + sqrtStartPrice: config.sqrtStartPrice, + migrationQuoteThreshold: config.migrationQuoteThreshold, + migrationSqrtPrice: migrationSqrtPrice(config), + curve: config.curve, + enableFirstSwapWithMinFee: config.enableFirstSwapWithMinFee ? 1 : 0, + quoteTokenFlag, + } as PoolConfig +} + +/** Pool `initialize_pool` writes: start price, zero volatility, `has_swap` 0. */ +function initialVirtualPool( + sqrtStartPrice: BN, + activationPoint: BN +): VirtualPool { + return { + poolState: { + sqrtPrice: sqrtStartPrice, + quoteReserve: new BN(0), + activationPoint, + hasSwap: 0, + volatilityTracker: { + volatilityAccumulator: new BN(0), + }, + }, + } as VirtualPool +} + +function quoteAtInitialization( + params: SimulatedQuoteBaseParams & + ( + | { + swapMode?: SwapMode.ExactIn | SwapMode.PartialFill + amountIn: BN + } + | { + swapMode: SwapMode.ExactOut + amountOut: BN + } + ) +): SwapQuote2Result { + const currentPoint = params.currentPoint ?? new BN(0) + const config = poolConfigFromParameters( + params.config, + params.quoteTokenFlag + ) + const virtualPool = initialVirtualPool( + params.config.sqrtStartPrice, + currentPoint + ) + const slippageBps = params.slippageBps ?? 0 + const hasReferral = params.hasReferral ?? false + const eligibleForFirstSwapWithMinFee = + params.eligibleForFirstSwapWithMinFee ?? false + const transferFees: QuoteTransferFees = params + + if (params.swapMode === SwapMode.ExactOut) { + return swapQuoteExactOut( + virtualPool, + config, + params.swapBaseForQuote, + params.amountOut, + slippageBps, + hasReferral, + currentPoint, + eligibleForFirstSwapWithMinFee, + transferFees + ) + } + + if (params.swapMode === SwapMode.PartialFill) { + return swapQuotePartialFill( + virtualPool, + config, + params.swapBaseForQuote, + params.amountIn, + slippageBps, + hasReferral, + currentPoint, + eligibleForFirstSwapWithMinFee, + transferFees + ) + } + + return swapQuoteExactIn( + virtualPool, + config, + params.swapBaseForQuote, + params.amountIn, + slippageBps, + hasReferral, + currentPoint, + eligibleForFirstSwapWithMinFee, + transferFees + ) +} + +export function getQuoteFromInputAmount( + params: SimulatedQuoteFromInputAmountParams +): SwapQuote2Result { + return quoteAtInitialization(params) +} + +export function getQuoteFromOutputAmount( + params: SimulatedQuoteFromOutputAmountParams +): SwapQuote2Result { + return quoteAtInitialization({ + ...params, + swapMode: SwapMode.ExactOut, + }) +} diff --git a/packages/dynamic-bonding-curve/src/helpers/token.ts b/packages/dynamic-bonding-curve/src/helpers/token.ts index 44994956..fdd7937e 100644 --- a/packages/dynamic-bonding-curve/src/helpers/token.ts +++ b/packages/dynamic-bonding-curve/src/helpers/token.ts @@ -11,16 +11,21 @@ import { ASSOCIATED_TOKEN_PROGRAM_ID, createAssociatedTokenAccountIdempotentInstruction, createCloseAccountInstruction, + ExtensionType, getAccount, getAssociatedTokenAddressSync, + getExtensionTypes, getMint, + getTransferFeeConfig, NATIVE_MINT, + NATIVE_MINT_2022, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, TokenAccountNotFoundError, TokenInvalidAccountOwnerError, + type Mint, } from '@solana/spl-token' -import { TokenType } from '../types' +import { type PoolConfig, type QuoteTransferFees, TokenType } from '../types' /** * Return an ATA address and an idempotent create instruction when the account is missing. @@ -58,11 +63,8 @@ export const getOrCreateATAInstruction = async ( ) return { ataPubkey: toAccount, ix } - } else { - /* handle error */ - console.error('Error::getOrCreateATAInstruction', e) - throw e } + throw e } } @@ -150,7 +152,11 @@ export async function getTokenDecimals( ? mintAddress : new PublicKey(mintAddress) - const tokenProgram = (await connection.getAccountInfo(mintPubkey)).owner + const mintAccount = await connection.getAccountInfo(mintPubkey) + if (!mintAccount) { + throw new Error(`Mint account ${mintPubkey.toBase58()} not found`) + } + const tokenProgram = mintAccount.owner const mintInfo = await getMint( connection, @@ -171,15 +177,15 @@ export function getTokenProgram(tokenType: TokenType): PublicKey { } /** - * Return the token type from the mint account owner, or `null` if the mint is missing. + * Return the token type from the mint account owner. */ export async function getTokenType( connection: Connection, tokenMint: PublicKey -): Promise { +): Promise { const accountInfo = await connection.getAccountInfo(tokenMint) if (!accountInfo) { - return null + throw new Error(`Mint account ${tokenMint.toBase58()} not found`) } return accountInfo.owner.equals(TOKEN_PROGRAM_ID) @@ -238,7 +244,7 @@ export async function cleanUpTokenAccountTx( tokenMint: PublicKey ): Promise<{ transaction: Transaction -}> { +} | null> { if (tokenMint.equals(NATIVE_MINT)) { const unwrapIx = unwrapSOLInstruction(owner, receiver) if (unwrapIx) { @@ -248,3 +254,80 @@ export async function cleanUpTokenAccountTx( return null } + +/** + * Return whether a mint has a non-zero transfer fee, active or scheduled, or a transfer fee config authority. + */ +export function hasTransferFeeOrConfigAuthority( + mint: Mint, + currentEpoch: number +): boolean { + const transferFeeConfig = getTransferFeeConfig(mint) + if (!transferFeeConfig) { + return false + } + if ( + !transferFeeConfig.transferFeeConfigAuthority.equals(PublicKey.default) + ) { + return true + } + + const { olderTransferFee, newerTransferFee } = transferFeeConfig + if (BigInt(currentEpoch) < newerTransferFee.epoch) { + return ( + olderTransferFee.transferFeeBasisPoints > 0 || + newerTransferFee.transferFeeBasisPoints > 0 + ) + } + return newerTransferFee.transferFeeBasisPoints > 0 +} + +/** + * Return whether a quote mint is supported without a token badge. + * SPL Token mints are supported. Token-2022 mints are supported when their only + * extensions are metadata and a zero transfer fee with no config authority. + */ +export function isSupportedQuoteMint( + mint: Mint, + currentEpoch: number +): boolean { + if (mint.address.equals(NATIVE_MINT_2022)) { + throw new Error('Token-2022 native mint is not supported as quote mint') + } + + const supportedExtensions = [ + ExtensionType.MetadataPointer, + ExtensionType.TokenMetadata, + ExtensionType.TransferFeeConfig, + ] + return ( + getExtensionTypes(mint.tlvData).every((extension) => + supportedExtensions.includes(extension) + ) && !hasTransferFeeOrConfigAuthority(mint, currentEpoch) + ) +} + +/** + * Fetch the quote mint and current epoch that swap quotes need for a Token-2022 quote mint. + * Returns an empty object for an SPL Token quote mint, which has no transfer fee. + */ +export async function getSwapQuoteTransferFees( + connection: Connection, + config: Pick, + commitment: Commitment = 'confirmed' +): Promise { + if (Number(config.quoteTokenFlag) !== TokenType.Token2022) { + return {} + } + + const [quoteMint, { epoch }] = await Promise.all([ + getMint( + connection, + config.quoteMint, + commitment, + TOKEN_2022_PROGRAM_ID + ), + connection.getEpochInfo(commitment), + ]) + return { quoteMint, currentEpoch: epoch } +} diff --git a/packages/dynamic-bonding-curve/src/helpers/utils.ts b/packages/dynamic-bonding-curve/src/helpers/utils.ts index 09c48dfe..383766e6 100644 --- a/packages/dynamic-bonding-curve/src/helpers/utils.ts +++ b/packages/dynamic-bonding-curve/src/helpers/utils.ts @@ -1,5 +1,9 @@ import BN from 'bn.js' -import { PublicKey, type GetProgramAccountsFilter } from '@solana/web3.js' +import { + PublicKey, + type GetProgramAccountsFilter, + type PublicKeyInitData, +} from '@solana/web3.js' import { NATIVE_MINT } from '@solana/spl-token' import { MAX_BASIS_POINT, FEE_DENOMINATOR } from '../constants' import Decimal from 'decimal.js' @@ -83,12 +87,9 @@ export function isDefaultLockedVesting(lockedVesting: { /** * Convert decimal to a BN - * @param value - The value - * @returns The BN + * @deprecated Use `fromDecimalToBN`. */ -export function convertDecimalToBN(value: Decimal): BN { - return new BN(value.floor().toFixed()) -} +export const convertDecimalToBN = fromDecimalToBN /** * Converts basis points (bps) to fee numerator @@ -113,3 +114,42 @@ export function feeNumeratorToBps(feeNumerator: BN): number { .div(new BN(FEE_DENOMINATOR)) .toNumber() } + +/** + * Convert an address to a PublicKey. + * @param value - The address + * @param name - The parameter name included in the error when the address is missing + * @returns The public key + */ +export function requirePublicKey( + value: PublicKeyInitData | undefined, + name: string +): PublicKey { + if (value === undefined) { + throw new Error(`${name} is required`) + } + return new PublicKey(value) +} + +/** + * Choose the wrapped-SOL account used while claiming a native-SOL fee. + * @param owner - The creator or partner who owns the fee + * @param receiver - The optional fee receiver + * @param tempWSolAcc - The temporary wrapped-SOL account, required when the receiver differs from the owner + * @returns The account that receives the wrapped SOL before it is unwrapped + */ +export function resolveTempWsolAccount( + owner: PublicKey, + receiver: PublicKey | undefined, + tempWSolAcc: PublicKey | undefined +): PublicKey { + if (!receiver || receiver.equals(owner)) { + return owner + } + if (!tempWSolAcc) { + throw new Error( + 'tempWSolAcc is required when receiver differs from the fee owner' + ) + } + return tempWSolAcc +} diff --git a/packages/dynamic-bonding-curve/src/helpers/validation.ts b/packages/dynamic-bonding-curve/src/helpers/validation.ts index 43b6232f..555c20d1 100644 --- a/packages/dynamic-bonding-curve/src/helpers/validation.ts +++ b/packages/dynamic-bonding-curve/src/helpers/validation.ts @@ -15,11 +15,14 @@ import { BIN_STEP_BPS_U128_DEFAULT, BIN_STEP_BPS_DEFAULT, MAX_BASIS_POINT, + MAX_BASE_TRANSFER_FEE_BPS, MAX_LOCK_DURATION_IN_SECONDS, U16_MAX, U24_MAX, U128_MAX, + U64_MAX, } from '../constants' +import { getBaseTransferFee, type EpochTransferFee } from '../math/transferFee' import { NATIVE_MINT_2022, TOKEN_2022_PROGRAM_ID, @@ -43,25 +46,24 @@ import { TokenDecimal, TokenType, TokenAuthorityOption, + TransferFeeWithheldAuthority, + MigratedTransferFeeAuthorityOption, type CreateConfigParams, type PoolConfig, + type TransferFeeParameters, } from '../types' import { Connection, PublicKey } from '@solana/web3.js' import { calculateLockedLiquidityBpsAtTime, getBaseTokenForSwap, getMigrationBaseToken, - getMigrationQuoteAmountFromMigrationQuoteThreshold, + getMigrationQuoteAmountFromThreshold, getMigrationThresholdPrice, getSwapAmountWithBuffer, getTotalTokenSupply, + validateCompoundingMigrationDeposit, } from './common' -import { - convertDecimalToBN, - isDefaultLockedVesting, - isNativeSol, -} from './utils' -import Decimal from 'decimal.js' +import { isDefaultLockedVesting, isNativeSol } from './utils' import { FEE_DENOMINATOR, MAX_FEE_NUMERATOR, @@ -69,12 +71,12 @@ import { MAX_RATE_LIMITER_DURATION_IN_SLOTS, MIN_FEE_NUMERATOR, } from '../constants' +import { getFeeNumeratorFromIncludedAmount } from '../math/poolFees/rateLimiter' import { - getFeeNumeratorFromIncludedAmount, getFeeSchedulerMaxBaseFeeNumerator, getFeeSchedulerMinBaseFeeNumerator, - toNumerator, -} from '../math' +} from '../math/poolFees/feeScheduler' +import { toNumerator } from '../math/utilsMath' const DAMM_V2_MIN_FEE_NUMERATOR = 100_000 const DAMM_V2_MAX_FEE_NUMERATOR = 990_000_000 @@ -132,10 +134,8 @@ export function validatePoolFees( ) { return false } - } - - // validate fee rate limiter if it exists - if (poolFees.baseFee.baseFeeMode === BaseFeeMode.RateLimiter) { + } else if (poolFees.baseFee.baseFeeMode === BaseFeeMode.RateLimiter) { + // validate fee rate limiter if it exists if ( !validateFeeRateLimiter( poolFees.baseFee.cliffFeeNumerator, @@ -148,6 +148,8 @@ export function validatePoolFees( ) { return false } + } else { + return false } } @@ -272,7 +274,7 @@ export function validateFeeRateLimiter( cliffFeeNumerator, referenceAmount, feeIncrementBps, - new BN(Number.MAX_SAFE_INTEGER) + U64_MAX ) return ( @@ -307,6 +309,24 @@ export function validateCollectFeeMode( ) } +/** + * Return whether the base token type is supported. + */ +export function validateTokenType(tokenType: TokenType): boolean { + return [TokenType.SPLToken, TokenType.Token2022].includes(tokenType) +} + +/** + * Return whether the migration option is supported. + */ +export function validateMigrationOption( + migrationOption: MigrationOption +): boolean { + return [MigrationOption.MET_DAMM, MigrationOption.MET_DAMM_V2].includes( + migrationOption + ) +} + /** * Return whether the selected migration path supports the base token type. */ @@ -686,7 +706,7 @@ export function validateCompoundingFeeBps( compoundingFeeBps: number ): boolean { if (collectFeeMode === MigratedCollectFeeMode.Compounding) { - return compoundingFeeBps > 0 && compoundingFeeBps <= MAX_BASIS_POINT + return compoundingFeeBps >= 0 && compoundingFeeBps <= MAX_BASIS_POINT } return compoundingFeeBps === 0 } @@ -744,8 +764,11 @@ export function validateMigratedPoolFee( } } - // if migratedPoolFee is none, it's valid - if (isNone()) { + // DAMM v2 Customizable always validates the migrated pool fee on-chain + const requiresMigratedPoolFee = + migrationOption === MigrationOption.MET_DAMM_V2 && + migrationFeeOption === MigrationFeeOption.Customizable + if (!requiresMigratedPoolFee && isNone()) { return true } @@ -804,6 +827,84 @@ export function validateMigratedPoolFee( return true } +/** + * Validate create_config2 transfer fee parameters and throw the first error encountered. + */ +export function validateTransferFeeParameters( + transferFeeParameters: TransferFeeParameters, + tokenType: number +): void { + const { + transferFeeBasisPoints, + withheldAuthority, + migratedTransferFeeAuthorityOption, + } = transferFeeParameters + + if (transferFeeBasisPoints === 0) { + if ( + withheldAuthority !== TransferFeeWithheldAuthority.Partner || + migratedTransferFeeAuthorityOption !== + MigratedTransferFeeAuthorityOption.Immutable + ) { + throw new Error('Invalid transfer fee parameters') + } + return + } + + if (tokenType !== TokenType.Token2022) { + throw new Error('Base transfer fee requires token type Token2022') + } + if ( + transferFeeBasisPoints < 0 || + transferFeeBasisPoints > MAX_BASE_TRANSFER_FEE_BPS || + ![ + TransferFeeWithheldAuthority.Partner, + TransferFeeWithheldAuthority.Creator, + ].includes(withheldAuthority) || + ![ + MigratedTransferFeeAuthorityOption.Immutable, + MigratedTransferFeeAuthorityOption.RevokeZeroFee, + MigratedTransferFeeAuthorityOption.Creator, + MigratedTransferFeeAuthorityOption.Partner, + ].includes(migratedTransferFeeAuthorityOption) + ) { + throw new Error('Invalid transfer fee parameters') + } +} + +function validateTransferFeeConfigMode(configParam: { + tokenSupply: CreateConfigParams['tokenSupply'] + lockedVesting: CreateConfigParams['lockedVesting'] + migrationFeeOption: number + migratedPoolFee: { collectFeeMode: number } +}): void { + const tokenSupply = configParam.tokenSupply + if ( + !tokenSupply || + !new BN(tokenSupply.preMigrationTokenSupply.toString()).eq( + new BN(tokenSupply.postMigrationTokenSupply.toString()) + ) + ) { + throw new Error('Transfer fee configs require a constant token supply') + } + if (!isDefaultLockedVesting(configParam.lockedVesting)) { + throw new Error('Transfer fee configs cannot include locked vesting') + } + if (configParam.migrationFeeOption !== MigrationFeeOption.Customizable) { + throw new Error( + 'Transfer fee configs require MigrationFeeOption.Customizable' + ) + } + if ( + configParam.migratedPoolFee.collectFeeMode !== + MigratedCollectFeeMode.Compounding + ) { + throw new Error( + 'Transfer fee configs require MigratedCollectFeeMode.Compounding' + ) + } +} + /** * Validate config parameters and throw the first actionable error encountered. */ @@ -817,15 +918,20 @@ export function validateConfigParameters( | { isTransferHook?: boolean transferHookProgram?: PublicKey + transferFeeParameters?: TransferFeeParameters | null + quoteMintHasTransferFee?: boolean + quoteEpochTransferFee?: EpochTransferFee | null } = false ) { - const { isTransferHook, transferHookProgram } = - typeof options === 'boolean' - ? { isTransferHook: options, transferHookProgram: undefined } - : { - isTransferHook: options.isTransferHook ?? false, - transferHookProgram: options.transferHookProgram, - } + const { + isTransferHook = false, + transferHookProgram, + transferFeeParameters, + quoteMintHasTransferFee = false, + quoteEpochTransferFee = null, + }: Exclude = typeof options === 'boolean' + ? { isTransferHook: options } + : options assertConfigAllowsNewPool({ baseFeeMode: configParam.poolFees?.baseFee?.baseFeeMode, @@ -877,6 +983,14 @@ export function validateConfigParameters( } } + if (!validateMigrationOption(configParam.migrationOption)) { + throw new Error('Invalid migration option') + } + + if (!validateTokenType(configParam.tokenType)) { + throw new Error('Invalid token type') + } + // migration and token type validation if ( !validateMigrationAndTokenType( @@ -1013,15 +1127,17 @@ export function validateConfigParameters( sqrtMigrationPrice, configParam.curve ) + const migratedCollectFeeMode = + configParam.migratedPoolFee?.collectFeeMode ?? + MigratedCollectFeeMode.QuoteToken const migrationBaseAmountForCurve = getMigrationBaseToken( - convertDecimalToBN( - getMigrationQuoteAmountFromMigrationQuoteThreshold( - new Decimal(configParam.migrationQuoteThreshold.toString()), - configParam.migrationFee.feePercentage - ) + getMigrationQuoteAmountFromThreshold( + configParam.migrationQuoteThreshold, + configParam.migrationFee.feePercentage ), sqrtMigrationPrice, - configParam.migrationOption + configParam.migrationOption, + migratedCollectFeeMode ) if ( swapBaseAmountForCurve.lte(new BN(0)) || @@ -1031,6 +1147,14 @@ export function validateConfigParameters( 'Invalid curve: swap base amount and migration base amount must both be greater than 0' ) } + if ( + swapBaseAmountForCurve.gt(U64_MAX) || + migrationBaseAmountForCurve.gt(U64_MAX) + ) { + throw new Error( + 'Invalid curve: swap base amount and migration base amount must fit in u64' + ) + } // the program requires at least 10% (1000 BPS) of liquidity to be locked at day 1 if ( @@ -1115,7 +1239,8 @@ export function validateConfigParameters( ) if ( configParam.lockedVesting.frequency.eq(new BN(0)) || - totalAmount.eq(new BN(0)) + totalAmount.eq(new BN(0)) || + totalAmount.gt(U64_MAX) ) { throw new Error('Invalid vesting parameters') } @@ -1139,14 +1264,13 @@ export function validateConfigParameters( ) const migrationBaseAmount = getMigrationBaseToken( - convertDecimalToBN( - getMigrationQuoteAmountFromMigrationQuoteThreshold( - new Decimal(configParam.migrationQuoteThreshold.toString()), - configParam.migrationFee.feePercentage - ) + getMigrationQuoteAmountFromThreshold( + configParam.migrationQuoteThreshold, + configParam.migrationFee.feePercentage ), sqrtMigrationPrice, - configParam.migrationOption + configParam.migrationOption, + migratedCollectFeeMode ) const swapBaseAmountBuffer = getSwapAmountWithBuffer( @@ -1168,6 +1292,31 @@ export function validateConfigParameters( throw new Error('Invalid token supply') } } + + if (transferFeeParameters) { + validateTransferFeeParameters( + transferFeeParameters, + configParam.tokenType + ) + } + if ( + (transferFeeParameters?.transferFeeBasisPoints ?? 0) > 0 || + quoteMintHasTransferFee + ) { + validateTransferFeeConfigMode(configParam) + } + + if (migratedCollectFeeMode === MigratedCollectFeeMode.Compounding) { + validateCompoundingMigrationDeposit({ + migrationQuoteThreshold: configParam.migrationQuoteThreshold, + migrationFeePercentage: configParam.migrationFee.feePercentage, + migrationSqrtPrice: sqrtMigrationPrice, + baseTransferFee: getBaseTransferFee( + transferFeeParameters?.transferFeeBasisPoints ?? 0 + ), + quoteTransferFee: quoteEpochTransferFee, + }) + } } /** @@ -1202,21 +1351,22 @@ export async function validateBalance( ) } } else { + let balance: BN try { const tokenBalance = await connection.getTokenAccountBalance(inputTokenAccount) - const balance = new BN(tokenBalance.value.amount) - - if (balance.lt(amountIn)) { - throw new Error( - `Insufficient token balance. Required: ${amountIn.toString()}, Found: ${balance.toString()}` - ) - } + balance = new BN(tokenBalance.value.amount) } catch (error) { throw new Error( `Failed to fetch token balance or token account doesn't exist ${error}` ) } + + if (balance.lt(amountIn)) { + throw new Error( + `Insufficient token balance. Required: ${amountIn.toString()}, Found: ${balance.toString()}` + ) + } } return true diff --git a/packages/dynamic-bonding-curve/src/idl/damm-v2/idl.json b/packages/dynamic-bonding-curve/src/idl/damm-v2/idl.json index 1c94c0fc..cb0f4920 100644 --- a/packages/dynamic-bonding-curve/src/idl/damm-v2/idl.json +++ b/packages/dynamic-bonding-curve/src/idl/damm-v2/idl.json @@ -2,7 +2,7 @@ "address": "cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG", "metadata": { "name": "cp_amm", - "version": "0.2.4", + "version": "0.2.5", "spec": "0.1.0", "description": "Created with Anchor" }, diff --git a/packages/dynamic-bonding-curve/src/idl/damm-v2/idl.ts b/packages/dynamic-bonding-curve/src/idl/damm-v2/idl.ts index 5fc77a02..21874963 100644 --- a/packages/dynamic-bonding-curve/src/idl/damm-v2/idl.ts +++ b/packages/dynamic-bonding-curve/src/idl/damm-v2/idl.ts @@ -8,7 +8,7 @@ export type CpAmm = { address: 'cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG' metadata: { name: 'cpAmm' - version: '0.2.4' + version: '0.2.5' spec: '0.1.0' description: 'Created with Anchor' } diff --git a/packages/dynamic-bonding-curve/src/idl/dynamic-bonding-curve/idl.json b/packages/dynamic-bonding-curve/src/idl/dynamic-bonding-curve/idl.json index f2f21583..e6930324 100644 --- a/packages/dynamic-bonding-curve/src/idl/dynamic-bonding-curve/idl.json +++ b/packages/dynamic-bonding-curve/src/idl/dynamic-bonding-curve/idl.json @@ -2,7 +2,7 @@ "address": "dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN", "metadata": { "name": "dynamic_bonding_curve", - "version": "0.2.1", + "version": "0.2.2", "spec": "0.1.0", "description": "Created with Anchor" }, @@ -696,7 +696,10 @@ "name": "fee_claimer" }, { - "name": "leftover_receiver" + "name": "leftover_receiver", + "docs": [ + "for transfer fee case (create_config2) token is restricted to constant supply" + ] }, { "name": "quote_mint", @@ -731,6 +734,76 @@ } ] }, + { + "name": "create_config2", + "discriminator": [ + 9, + 202, + 24, + 203, + 143, + 212, + 252, + 81 + ], + "accounts": [ + { + "name": "config", + "writable": true, + "signer": true + }, + { + "name": "fee_claimer" + }, + { + "name": "leftover_receiver", + "docs": [ + "for transfer fee case (create_config2) token is restricted to constant supply" + ] + }, + { + "name": "quote_mint", + "docs": [ + "quote mint" + ] + }, + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + }, + { + "name": "event_authority" + }, + { + "name": "program" + } + ], + "args": [ + { + "name": "config_parameters", + "type": { + "defined": { + "name": "ConfigParameters" + } + } + }, + { + "name": "transfer_fee_parameters", + "type": { + "option": { + "defined": { + "name": "TransferFeeParameters" + } + } + } + } + ] + }, { "name": "create_config_with_transfer_hook", "discriminator": [ @@ -1488,7 +1561,7 @@ { "name": "base_mint", "docs": [ - "Unique token mint address, initialize in contract" + "Unique token mint address, initialized in the handler" ], "writable": true, "signer": true @@ -1649,7 +1722,7 @@ { "name": "base_mint", "docs": [ - "Unique token mint address, initialize in contract" + "Unique token mint address, initialized in the handler" ], "writable": true, "signer": true @@ -3444,16 +3517,16 @@ ] }, { - "name": "EvtCreateConfig", + "name": "EvtCreateConfig3", "discriminator": [ - 131, - 207, - 180, - 174, - 180, - 73, - 165, - 54 + 216, + 4, + 167, + 161, + 37, + 138, + 124, + 128 ] }, { @@ -3651,6 +3724,32 @@ 231 ] }, + { + "name": "EvtSwap3", + "discriminator": [ + 187, + 119, + 248, + 13, + 175, + 255, + 64, + 63 + ] + }, + { + "name": "EvtSwap3WithTransferHook", + "discriminator": [ + 156, + 88, + 188, + 49, + 227, + 146, + 165, + 93 + ] + }, { "name": "EvtUpdatePoolCreator", "discriminator": [ @@ -4113,12 +4212,22 @@ { "code": 6081, "name": "QuoteMintHasNonZeroTransferFee", - "msg": "Quote mint has a non zero transfer fee" + "msg": "Quote mint has a non zero transfer fee or a live transfer fee config authority" }, { "code": 6082, "name": "DeprecatedMigrationOption", "msg": "Deprecated migration option" + }, + { + "code": 6083, + "name": "FeeInverseIsIncorrect", + "msg": "Transfer fee inverse calculation is incorrect" + }, + { + "code": 6084, + "name": "InvalidTransferFeeParameters", + "msg": "Invalid transfer fee parameters" } ], "types": [ @@ -4778,10 +4887,7 @@ } }, { - "name": "EvtCreateConfig", - "docs": [ - "Create config" - ], + "name": "EvtCreateConfig3", "type": { "kind": "struct", "fields": [ @@ -4798,100 +4904,22 @@ "type": "pubkey" }, { - "name": "owner", + "name": "leftover_receiver", "type": "pubkey" }, { - "name": "pool_fees", + "name": "config_parameters", "type": { "defined": { - "name": "PoolFeeParameters" + "name": "ConfigParameters" } } }, { - "name": "collect_fee_mode", - "type": "u8" - }, - { - "name": "migration_option", - "type": "u8" - }, - { - "name": "activation_type", - "type": "u8" - }, - { - "name": "token_decimal", - "type": "u8" - }, - { - "name": "token_type", - "type": "u8" - }, - { - "name": "partner_permanent_locked_liquidity_percentage", - "type": "u8" - }, - { - "name": "partner_liquidity_percentage", - "type": "u8" - }, - { - "name": "creator_permanent_locked_liquidity_percentage", - "type": "u8" - }, - { - "name": "creator_liquidity_percentage", - "type": "u8" - }, - { - "name": "swap_base_amount", - "type": "u64" - }, - { - "name": "migration_quote_threshold", - "type": "u64" - }, - { - "name": "migration_base_amount", - "type": "u64" - }, - { - "name": "sqrt_start_price", - "type": "u128" - }, - { - "name": "locked_vesting", + "name": "transfer_fee_parameters", "type": { "defined": { - "name": "LockedVestingParams" - } - } - }, - { - "name": "migration_fee_option", - "type": "u8" - }, - { - "name": "fixed_token_supply_flag", - "type": "u8" - }, - { - "name": "pre_migration_token_supply", - "type": "u64" - }, - { - "name": "post_migration_token_supply", - "type": "u64" - }, - { - "name": "curve", - "type": { - "vec": { - "defined": { - "name": "LiquidityDistributionParameters" - } + "name": "TransferFeeParameters" } } } @@ -5329,6 +5357,150 @@ ] } }, + { + "name": "EvtSwap3", + "type": { + "kind": "struct", + "fields": [ + { + "name": "pool", + "type": "pubkey" + }, + { + "name": "config", + "type": "pubkey" + }, + { + "name": "trade_direction", + "type": "u8" + }, + { + "name": "swap_mode", + "type": "u8" + }, + { + "name": "has_referral", + "type": "bool" + }, + { + "name": "fee_on_base_token", + "type": "bool" + }, + { + "name": "included_transfer_fee_amount_in", + "type": "u64" + }, + { + "name": "excluded_transfer_fee_amount_in", + "type": "u64" + }, + { + "name": "included_transfer_fee_amount_out", + "type": "u64" + }, + { + "name": "excluded_transfer_fee_amount_out", + "type": "u64" + }, + { + "name": "trading_fee", + "type": "u64" + }, + { + "name": "protocol_fee", + "type": "u64" + }, + { + "name": "referral_fee", + "type": "u64" + }, + { + "name": "next_sqrt_price", + "type": "u128" + }, + { + "name": "quote_reserve", + "type": "u64" + }, + { + "name": "current_timestamp", + "type": "u64" + } + ] + } + }, + { + "name": "EvtSwap3WithTransferHook", + "type": { + "kind": "struct", + "fields": [ + { + "name": "pool", + "type": "pubkey" + }, + { + "name": "config", + "type": "pubkey" + }, + { + "name": "trade_direction", + "type": "u8" + }, + { + "name": "swap_mode", + "type": "u8" + }, + { + "name": "has_referral", + "type": "bool" + }, + { + "name": "fee_on_base_token", + "type": "bool" + }, + { + "name": "included_transfer_fee_amount_in", + "type": "u64" + }, + { + "name": "excluded_transfer_fee_amount_in", + "type": "u64" + }, + { + "name": "included_transfer_fee_amount_out", + "type": "u64" + }, + { + "name": "excluded_transfer_fee_amount_out", + "type": "u64" + }, + { + "name": "trading_fee", + "type": "u64" + }, + { + "name": "protocol_fee", + "type": "u64" + }, + { + "name": "referral_fee", + "type": "u64" + }, + { + "name": "next_sqrt_price", + "type": "u128" + }, + { + "name": "quote_reserve", + "type": "u64" + }, + { + "name": "current_timestamp", + "type": "u64" + } + ] + } + }, { "name": "EvtUpdatePoolCreator", "type": { @@ -5962,6 +6134,27 @@ } } }, + { + "name": "transfer_fee_basis_points", + "docs": [ + "Base mint transfer fee in basis points" + ], + "type": "u16" + }, + { + "name": "transfer_fee_withheld_authority", + "docs": [ + "See TransferFeeWithheldAuthority (0 means partner, 1 means creator)" + ], + "type": "u8" + }, + { + "name": "migrated_transfer_fee_authority_option", + "docs": [ + "See MigratedTransferFeeAuthorityOption" + ], + "type": "u8" + }, { "name": "padding_0", "docs": [ @@ -5970,7 +6163,7 @@ "type": { "array": [ "u8", - 14 + 10 ] } }, @@ -6829,6 +7022,26 @@ ] } }, + { + "name": "TransferFeeParameters", + "type": { + "kind": "struct", + "fields": [ + { + "name": "transfer_fee_basis_points", + "type": "u16" + }, + { + "name": "withheld_authority", + "type": "u8" + }, + { + "name": "migrated_transfer_fee_authority_option", + "type": "u8" + } + ] + } + }, { "name": "TransferHookAccountsInfo", "type": { diff --git a/packages/dynamic-bonding-curve/src/idl/dynamic-bonding-curve/idl.ts b/packages/dynamic-bonding-curve/src/idl/dynamic-bonding-curve/idl.ts index 34b2a300..6b369489 100644 --- a/packages/dynamic-bonding-curve/src/idl/dynamic-bonding-curve/idl.ts +++ b/packages/dynamic-bonding-curve/src/idl/dynamic-bonding-curve/idl.ts @@ -8,7 +8,7 @@ export type DynamicBondingCurve = { address: 'dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN' metadata: { name: 'dynamicBondingCurve' - version: '0.2.1' + version: '0.2.2' spec: '0.1.0' description: 'Created with Anchor' } @@ -524,6 +524,58 @@ export type DynamicBondingCurve = { }, { name: 'leftoverReceiver' + docs: [ + 'for transfer fee case (create_config2) token is restricted to constant supply', + ] + }, + { + name: 'quoteMint' + docs: ['quote mint'] + }, + { + name: 'payer' + writable: true + signer: true + }, + { + name: 'systemProgram' + address: '11111111111111111111111111111111' + }, + { + name: 'eventAuthority' + }, + { + name: 'program' + }, + ] + args: [ + { + name: 'configParameters' + type: { + defined: { + name: 'configParameters' + } + } + }, + ] + }, + { + name: 'createConfig2' + discriminator: [9, 202, 24, 203, 143, 212, 252, 81] + accounts: [ + { + name: 'config' + writable: true + signer: true + }, + { + name: 'feeClaimer' + }, + { + name: 'leftoverReceiver' + docs: [ + 'for transfer fee case (create_config2) token is restricted to constant supply', + ] }, { name: 'quoteMint' @@ -554,6 +606,16 @@ export type DynamicBondingCurve = { } } }, + { + name: 'transferFeeParameters' + type: { + option: { + defined: { + name: 'transferFeeParameters' + } + } + } + }, ] }, { @@ -1167,7 +1229,9 @@ export type DynamicBondingCurve = { }, { name: 'baseMint' - docs: ['Unique token mint address, initialize in contract'] + docs: [ + 'Unique token mint address, initialized in the handler', + ] writable: true signer: true }, @@ -1303,7 +1367,9 @@ export type DynamicBondingCurve = { }, { name: 'baseMint' - docs: ['Unique token mint address, initialize in contract'] + docs: [ + 'Unique token mint address, initialized in the handler', + ] writable: true signer: true }, @@ -2670,8 +2736,8 @@ export type DynamicBondingCurve = { discriminator: [21, 6, 153, 120, 68, 116, 28, 177] }, { - name: 'evtCreateConfig' - discriminator: [131, 207, 180, 174, 180, 73, 165, 54] + name: 'evtCreateConfig3' + discriminator: [216, 4, 167, 161, 37, 138, 124, 128] }, { name: 'evtCreateConfigV2' @@ -2733,6 +2799,14 @@ export type DynamicBondingCurve = { name: 'evtSwap2WithTransferHook' discriminator: [134, 59, 168, 120, 94, 51, 114, 231] }, + { + name: 'evtSwap3' + discriminator: [187, 119, 248, 13, 175, 255, 64, 63] + }, + { + name: 'evtSwap3WithTransferHook' + discriminator: [156, 88, 188, 49, 227, 146, 165, 93] + }, { name: 'evtUpdatePoolCreator' discriminator: [107, 225, 165, 237, 91, 158, 213, 220] @@ -3159,13 +3233,23 @@ export type DynamicBondingCurve = { { code: 6081 name: 'quoteMintHasNonZeroTransferFee' - msg: 'Quote mint has a non zero transfer fee' + msg: 'Quote mint has a non zero transfer fee or a live transfer fee config authority' }, { code: 6082 name: 'deprecatedMigrationOption' msg: 'Deprecated migration option' }, + { + code: 6083 + name: 'feeInverseIsIncorrect' + msg: 'Transfer fee inverse calculation is incorrect' + }, + { + code: 6084 + name: 'invalidTransferFeeParameters' + msg: 'Invalid transfer fee parameters' + }, ] types: [ { @@ -3783,8 +3867,7 @@ export type DynamicBondingCurve = { } }, { - name: 'evtCreateConfig' - docs: ['Create config'] + name: 'evtCreateConfig3' type: { kind: 'struct' fields: [ @@ -3801,100 +3884,22 @@ export type DynamicBondingCurve = { type: 'pubkey' }, { - name: 'owner' + name: 'leftoverReceiver' type: 'pubkey' }, { - name: 'poolFees' + name: 'configParameters' type: { defined: { - name: 'poolFeeParameters' + name: 'configParameters' } } }, { - name: 'collectFeeMode' - type: 'u8' - }, - { - name: 'migrationOption' - type: 'u8' - }, - { - name: 'activationType' - type: 'u8' - }, - { - name: 'tokenDecimal' - type: 'u8' - }, - { - name: 'tokenType' - type: 'u8' - }, - { - name: 'partnerPermanentLockedLiquidityPercentage' - type: 'u8' - }, - { - name: 'partnerLiquidityPercentage' - type: 'u8' - }, - { - name: 'creatorPermanentLockedLiquidityPercentage' - type: 'u8' - }, - { - name: 'creatorLiquidityPercentage' - type: 'u8' - }, - { - name: 'swapBaseAmount' - type: 'u64' - }, - { - name: 'migrationQuoteThreshold' - type: 'u64' - }, - { - name: 'migrationBaseAmount' - type: 'u64' - }, - { - name: 'sqrtStartPrice' - type: 'u128' - }, - { - name: 'lockedVesting' + name: 'transferFeeParameters' type: { defined: { - name: 'lockedVestingParams' - } - } - }, - { - name: 'migrationFeeOption' - type: 'u8' - }, - { - name: 'fixedTokenSupplyFlag' - type: 'u8' - }, - { - name: 'preMigrationTokenSupply' - type: 'u64' - }, - { - name: 'postMigrationTokenSupply' - type: 'u64' - }, - { - name: 'curve' - type: { - vec: { - defined: { - name: 'liquidityDistributionParameters' - } + name: 'transferFeeParameters' } } }, @@ -4330,6 +4335,150 @@ export type DynamicBondingCurve = { ] } }, + { + name: 'evtSwap3' + type: { + kind: 'struct' + fields: [ + { + name: 'pool' + type: 'pubkey' + }, + { + name: 'config' + type: 'pubkey' + }, + { + name: 'tradeDirection' + type: 'u8' + }, + { + name: 'swapMode' + type: 'u8' + }, + { + name: 'hasReferral' + type: 'bool' + }, + { + name: 'feeOnBaseToken' + type: 'bool' + }, + { + name: 'includedTransferFeeAmountIn' + type: 'u64' + }, + { + name: 'excludedTransferFeeAmountIn' + type: 'u64' + }, + { + name: 'includedTransferFeeAmountOut' + type: 'u64' + }, + { + name: 'excludedTransferFeeAmountOut' + type: 'u64' + }, + { + name: 'tradingFee' + type: 'u64' + }, + { + name: 'protocolFee' + type: 'u64' + }, + { + name: 'referralFee' + type: 'u64' + }, + { + name: 'nextSqrtPrice' + type: 'u128' + }, + { + name: 'quoteReserve' + type: 'u64' + }, + { + name: 'currentTimestamp' + type: 'u64' + }, + ] + } + }, + { + name: 'evtSwap3WithTransferHook' + type: { + kind: 'struct' + fields: [ + { + name: 'pool' + type: 'pubkey' + }, + { + name: 'config' + type: 'pubkey' + }, + { + name: 'tradeDirection' + type: 'u8' + }, + { + name: 'swapMode' + type: 'u8' + }, + { + name: 'hasReferral' + type: 'bool' + }, + { + name: 'feeOnBaseToken' + type: 'bool' + }, + { + name: 'includedTransferFeeAmountIn' + type: 'u64' + }, + { + name: 'excludedTransferFeeAmountIn' + type: 'u64' + }, + { + name: 'includedTransferFeeAmountOut' + type: 'u64' + }, + { + name: 'excludedTransferFeeAmountOut' + type: 'u64' + }, + { + name: 'tradingFee' + type: 'u64' + }, + { + name: 'protocolFee' + type: 'u64' + }, + { + name: 'referralFee' + type: 'u64' + }, + { + name: 'nextSqrtPrice' + type: 'u128' + }, + { + name: 'quoteReserve' + type: 'u64' + }, + { + name: 'currentTimestamp' + type: 'u64' + }, + ] + } + }, { name: 'evtUpdatePoolCreator' type: { @@ -4910,11 +5059,28 @@ export type DynamicBondingCurve = { } } }, + { + name: 'transferFeeBasisPoints' + docs: ['Base mint transfer fee in basis points'] + type: 'u16' + }, + { + name: 'transferFeeWithheldAuthority' + docs: [ + 'See TransferFeeWithheldAuthority (0 means partner, 1 means creator)', + ] + type: 'u8' + }, + { + name: 'migratedTransferFeeAuthorityOption' + docs: ['See MigratedTransferFeeAuthorityOption'] + type: 'u8' + }, { name: 'padding0' docs: ['Padding for future use'] type: { - array: ['u8', 14] + array: ['u8', 10] } }, { @@ -5618,6 +5784,26 @@ export type DynamicBondingCurve = { ] } }, + { + name: 'transferFeeParameters' + type: { + kind: 'struct' + fields: [ + { + name: 'transferFeeBasisPoints' + type: 'u16' + }, + { + name: 'withheldAuthority' + type: 'u8' + }, + { + name: 'migratedTransferFeeAuthorityOption' + type: 'u8' + }, + ] + } + }, { name: 'transferHookAccountsInfo' type: { diff --git a/packages/dynamic-bonding-curve/src/math/curve.ts b/packages/dynamic-bonding-curve/src/math/curve.ts index ad40e169..34318725 100644 --- a/packages/dynamic-bonding-curve/src/math/curve.ts +++ b/packages/dynamic-bonding-curve/src/math/curve.ts @@ -1,9 +1,23 @@ import BN from 'bn.js' import { SafeMath } from './safeMath' import { mulDiv } from './utilsMath' -import { RESOLUTION, U128_MAX } from '../constants' +import { RESOLUTION, U128_MAX, U64_MAX } from '../constants' import { Rounding } from '../types' +function requireU64(value: BN): BN { + if (value.gt(U64_MAX)) { + throw new Error('Math overflow') + } + return value +} + +function requireU128(value: BN): BN { + if (value.gt(U128_MAX)) { + throw new Error('Math overflow') + } + return value +} + /** * Gets the initial liquidity from delta quote * Formula: Δb = L (√P_upper - √P_lower) => L = Δb / (√P_upper - √P_lower) @@ -20,7 +34,11 @@ export function getInitialLiquidityFromDeltaQuote( const priceDelta = SafeMath.sub(sqrtPrice, sqrtMinPrice) const quoteAmountShifted = SafeMath.shl(quoteAmount, 128) - return SafeMath.div(quoteAmountShifted, priceDelta) // round down + const liquidity = SafeMath.div(quoteAmountShifted, priceDelta) // round down + if (liquidity.gt(U128_MAX)) { + throw new Error('Liquidity from delta quote exceeds u128') + } + return liquidity } /** @@ -61,14 +79,14 @@ export function getDeltaAmountBaseUnsigned( liquidity: BN, round: Rounding ): BN { - const result = getDeltaAmountBaseUnsigned256( - lowerSqrtPrice, - upperSqrtPrice, - liquidity, - round + return requireU64( + getDeltaAmountBaseUnsigned256( + lowerSqrtPrice, + upperSqrtPrice, + liquidity, + round + ) ) - - return result } export function getDeltaAmountBaseUnsigned256( @@ -124,14 +142,14 @@ export function getDeltaAmountQuoteUnsigned( liquidity: BN, round: Rounding ): BN { - const result = getDeltaAmountQuoteUnsigned256( - lowerSqrtPrice, - upperSqrtPrice, - liquidity, - round + return requireU64( + getDeltaAmountQuoteUnsigned256( + lowerSqrtPrice, + upperSqrtPrice, + liquidity, + round + ) ) - - return result } export function getDeltaAmountQuoteUnsigned256( @@ -190,6 +208,9 @@ export function getNextSqrtPriceFromInput( amountIn: BN, baseForQuote: boolean ): BN { + requireU128(sqrtPrice) + requireU128(liquidity) + requireU64(amountIn) if (sqrtPrice.isZero()) { throw new Error('sqrt_price must be greater than 0') } @@ -227,6 +248,9 @@ export function getNextSqrtPriceFromOutput( amountOut: BN, baseForQuote: boolean ): BN { + requireU128(sqrtPrice) + requireU128(liquidity) + requireU64(amountOut) if (sqrtPrice.isZero()) { throw new Error('sqrt_price must be greater than 0') } @@ -271,9 +295,7 @@ export function getNextSqrtPriceFromQuoteAmountOutRoundingDown( const quotient = SafeMath.div(numerator, liquidity) // √P - quotient - const result = SafeMath.sub(sqrtPrice, quotient) - - return result + return requireU128(SafeMath.sub(sqrtPrice, quotient)) } /** @@ -306,7 +328,7 @@ export function getNextSqrtPriceFromBaseAmountOutRoundingUp( } // √P * L / (L - Δx * √P) with rounding up - return mulDiv(liquidity, sqrtPrice, denominator, Rounding.Up) + return requireU128(mulDiv(liquidity, sqrtPrice, denominator, Rounding.Up)) } /** @@ -319,7 +341,6 @@ export function getNextSqrtPriceFromBaseAmountOutRoundingUp( * change and not guarantee exact output. * * Formula: √P' = √P * L / (L + Δx * √P) - * If Δx * √P overflows, use alternate form √P' = L / (L/√P + Δx) * * @param sqrtPrice Current sqrt price * @param liquidity Liquidity @@ -335,20 +356,9 @@ export function getNextSqrtPriceFromBaseAmountInRoundingUp( return sqrtPrice } - // Check for potential overflow in Δx * √P const product = SafeMath.mul(amount, sqrtPrice) - - // Check if product would overflow - if so, use alternate form - if (product.gt(U128_MAX)) { - // Alternate form: √P' = L / (L/√P + Δx) - const quotient = SafeMath.div(liquidity, sqrtPrice) - const denominator = SafeMath.add(quotient, amount) - return SafeMath.div(liquidity, denominator) - } - - // Standard form: √P' = √P * L / (L + Δx * √P) const denominator = SafeMath.add(liquidity, product) - return mulDiv(liquidity, sqrtPrice, denominator, Rounding.Up) + return requireU128(mulDiv(liquidity, sqrtPrice, denominator, Rounding.Up)) } /** @@ -379,5 +389,5 @@ export function getNextSqrtPriceFromQuoteAmountInRoundingDown( ) // √P' = √P + Δy / L - return SafeMath.add(sqrtPrice, quotient) + return requireU128(SafeMath.add(sqrtPrice, quotient)) } diff --git a/packages/dynamic-bonding-curve/src/math/feeMath.ts b/packages/dynamic-bonding-curve/src/math/feeMath.ts index da6dce14..632eef59 100644 --- a/packages/dynamic-bonding-curve/src/math/feeMath.ts +++ b/packages/dynamic-bonding-curve/src/math/feeMath.ts @@ -2,11 +2,11 @@ import BN from 'bn.js' import { SafeMath } from './safeMath' import { mulDiv } from './utilsMath' import { - MAX_BASIS_POINT, FEE_DENOMINATOR, HOST_FEE_PERCENT, MAX_FEE_NUMERATOR, PROTOCOL_FEE_PERCENT, + U64_MAX, } from '../constants' import { CollectFeeMode, @@ -21,29 +21,6 @@ import { import { getBaseFeeHandler } from './poolFees/baseFee' import { getVariableFeeNumerator } from './poolFees/dynamicFee' -/** - * Convert basis points to fee numerator - * @param bps - Basis points - * @param feeDenominator - Fee denominator - * @returns Fee numerator - * @throws Error if calculation fails due to overflow or type conversion - */ -export function toNumerator(bps: BN, feeDenominator: BN): BN { - try { - const numerator = mulDiv( - bps, - feeDenominator, - new BN(MAX_BASIS_POINT), - Rounding.Down - ) - return numerator - } catch (error) { - throw new Error( - `Type cast failed or calculation overflow in toNumerator ${error}` - ) - } -} - /** * Get fee mode * @param collectFeeMode Collect fee mode @@ -73,8 +50,7 @@ export function getFeeMode( feesOnInput = false feesOnBaseToken = true } - } else { - // CollectFeeMode.QuoteToken + } else if (collectFeeMode === CollectFeeMode.QuoteToken) { if (tradeDirection === TradeDirection.BaseToQuote) { feesOnInput = false feesOnBaseToken = false @@ -83,6 +59,8 @@ export function getFeeMode( feesOnInput = true feesOnBaseToken = false } + } else { + throw new Error('Invalid collect fee mode') } return { @@ -289,6 +267,9 @@ export function getIncludedFeeAmount( SafeMath.sub(new BN(FEE_DENOMINATOR), tradeFeeNumerator), Rounding.Up ) + if (includedFeeAmount.gt(U64_MAX)) { + throw new Error('Type cast failed') + } const feeAmount = SafeMath.sub(includedFeeAmount, excludedFeeAmount) return [includedFeeAmount, feeAmount] } diff --git a/packages/dynamic-bonding-curve/src/math/index.ts b/packages/dynamic-bonding-curve/src/math/index.ts index 6c51a502..80ac061f 100644 --- a/packages/dynamic-bonding-curve/src/math/index.ts +++ b/packages/dynamic-bonding-curve/src/math/index.ts @@ -2,5 +2,6 @@ export * from './curve' export * from './feeMath' export * from './safeMath' export * from './swapQuote' +export * from './transferFee' export * from './utilsMath' export * from './poolFees' diff --git a/packages/dynamic-bonding-curve/src/math/poolFees/baseFee.ts b/packages/dynamic-bonding-curve/src/math/poolFees/baseFee.ts index dc367a52..df216c3f 100644 --- a/packages/dynamic-bonding-curve/src/math/poolFees/baseFee.ts +++ b/packages/dynamic-bonding-curve/src/math/poolFees/baseFee.ts @@ -16,7 +16,10 @@ import { getBaseFeeNumerator, getFeeSchedulerMinBaseFeeNumerator, } from './feeScheduler' -import { validateFeeRateLimiter, validateFeeScheduler } from '../../helpers' +import { + validateFeeRateLimiter, + validateFeeScheduler, +} from '../../helpers/validation' /** * Fee Rate Limiter implementation diff --git a/packages/dynamic-bonding-curve/src/math/poolFees/feeScheduler.ts b/packages/dynamic-bonding-curve/src/math/poolFees/feeScheduler.ts index 20080292..efa23dea 100644 --- a/packages/dynamic-bonding-curve/src/math/poolFees/feeScheduler.ts +++ b/packages/dynamic-bonding-curve/src/math/poolFees/feeScheduler.ts @@ -151,7 +151,10 @@ export function getBaseFeeNumerator( return cliffFeeNumerator } - const period = currentPoint.sub(activationPoint).div(periodFrequency) + const period = SafeMath.div( + SafeMath.sub(currentPoint, activationPoint), + periodFrequency + ) return getBaseFeeNumeratorByPeriod( cliffFeeNumerator, diff --git a/packages/dynamic-bonding-curve/src/math/poolFees/rateLimiter.ts b/packages/dynamic-bonding-curve/src/math/poolFees/rateLimiter.ts index 5cbd305d..5fd8905c 100644 --- a/packages/dynamic-bonding-curve/src/math/poolFees/rateLimiter.ts +++ b/packages/dynamic-bonding-curve/src/math/poolFees/rateLimiter.ts @@ -1,8 +1,7 @@ import BN from 'bn.js' -import { mulDiv, sqrt } from '../utilsMath' +import { mulDiv, sqrt, toNumerator } from '../utilsMath' import { FEE_DENOMINATOR, MAX_FEE_NUMERATOR, U64_MAX } from '../../constants' import { Rounding, TradeDirection } from '../../types' -import { toNumerator } from '../feeMath' /** * Check if rate limiter is applied based on current conditions diff --git a/packages/dynamic-bonding-curve/src/math/safeMath.ts b/packages/dynamic-bonding-curve/src/math/safeMath.ts index 7a0687a3..63ae60e6 100644 --- a/packages/dynamic-bonding-curve/src/math/safeMath.ts +++ b/packages/dynamic-bonding-curve/src/math/safeMath.ts @@ -1,5 +1,5 @@ import BN from 'bn.js' -import { RESOLUTION } from '../constants' +import { RESOLUTION, U128_MAX } from '../constants' /** * Safe math operations for BN @@ -88,43 +88,62 @@ export class SafeMath { } } +const MAX_EXPONENTIAL = 0x80000 +const I32_MIN = new BN('-2147483648') + +function mulQ64(left: BN, right: BN): BN { + const product = left.mul(right) + if (product.gt(U128_MAX)) { + throw new Error('Math overflow') + } + return product.shrn(RESOLUTION) +} + /** - * Safe power function for BN with scaling - * @param base Base number (scaled by RESOLUTION) - * @param exponent Exponent (can be negative) - * @param scaling Whether to apply RESOLUTION scaling to the result - * @returns base^exponent + * Q64 power, matching the program. A base at or above 1 is inverted through + * u128::MAX so each multiplication stays inside u128. */ export function pow(base: BN, exponent: BN, scaling: boolean = true): BN { - const ONE = new BN(1).shln(RESOLUTION) + const one = new BN(1).shln(RESOLUTION) - // special cases - if (exponent.isZero()) return ONE - if (base.isZero()) return new BN(0) - if (base.eq(ONE)) return ONE + if (exponent.eq(I32_MIN) || base.isNeg() || base.gt(U128_MAX)) { + throw new Error('Math overflow') + } + if (exponent.isZero()) { + return one + } - // handle negative exponent - const isNegative = exponent.isNeg() - const absExponent = isNegative ? exponent.neg() : exponent + let invert = exponent.isNeg() + const exp = invert ? exponent.neg() : exponent + if (exp.gte(new BN(MAX_EXPONENTIAL))) { + throw new Error('Math overflow') + } - // binary exponentiation - let result = ONE - let currentBase = base - let exp = absExponent + let squaredBase = base + let result = one + if (squaredBase.gte(result)) { + if (squaredBase.isZero()) { + throw new Error('Math overflow') + } + squaredBase = U128_MAX.div(squaredBase) + invert = !invert + } - while (!exp.isZero()) { - if (exp.and(new BN(1)).eq(new BN(1))) { - result = SafeMath.div(SafeMath.mul(result, currentBase), ONE) + let remaining = exp + while (!remaining.isZero()) { + if (!remaining.and(new BN(1)).isZero()) { + result = mulQ64(result, squaredBase) } - currentBase = SafeMath.div(SafeMath.mul(currentBase, currentBase), ONE) - exp = exp.shrn(1) + squaredBase = mulQ64(squaredBase, squaredBase) + remaining = remaining.shrn(1) } - // handle negative exponent - if (isNegative) { - result = SafeMath.div(ONE.mul(ONE), result) + if (result.isZero()) { + throw new Error('Math overflow') + } + if (invert) { + result = U128_MAX.div(result) } - // apply scaling if needed - return scaling ? result : SafeMath.div(result, ONE) + return scaling ? result : result.shrn(RESOLUTION) } diff --git a/packages/dynamic-bonding-curve/src/math/swapQuote.ts b/packages/dynamic-bonding-curve/src/math/swapQuote.ts index cc1d7bb4..7280f5d0 100644 --- a/packages/dynamic-bonding-curve/src/math/swapQuote.ts +++ b/packages/dynamic-bonding-curve/src/math/swapQuote.ts @@ -2,7 +2,9 @@ import BN from 'bn.js' import { SafeMath } from './safeMath' import { getDeltaAmountBaseUnsigned, + getDeltaAmountBaseUnsigned256, getDeltaAmountQuoteUnsigned, + getDeltaAmountQuoteUnsigned256, getNextSqrtPriceFromInput, getNextSqrtPriceFromOutput, } from './curve' @@ -26,7 +28,96 @@ import { type SwapResult2, type VirtualPool, SwapQuote2Result, + type QuoteTransferFees, } from '../types' +import { + calculateTransferFeeExcludedAmount, + calculateTransferFeeIncludedAmount, + resolveSwapTransferFees, +} from './transferFee' +import { MAX_BASIS_POINT, U64_MAX } from '../constants' + +function addU64(total: BN, amount: BN): BN { + const sum = total.add(amount) + if (sum.gt(U64_MAX)) { + throw new Error('Math overflow') + } + return sum +} + +function u64(amount: BN): BN { + if (amount.gt(U64_MAX)) { + throw new Error('Math overflow') + } + return amount +} + +function minimumAmountOutWithSlippage(amount: BN, slippageBps: number): BN { + if (slippageBps > 0) { + return amount + .mul(new BN(MAX_BASIS_POINT - slippageBps)) + .div(new BN(MAX_BASIS_POINT)) + } + return amount +} + +function maximumAmountInWithSlippage(amount: BN, slippageBps: number): BN { + if (slippageBps > 0) { + return amount + .mul(new BN(MAX_BASIS_POINT + slippageBps)) + .div(new BN(MAX_BASIS_POINT)) + } + return amount +} + +// the caller still has to bundle the swap after initialize pool in the same transaction, without CPI +function isFirstSwapWithMinFee( + virtualPool: VirtualPool, + config: PoolConfig, + hasReferral: boolean, + eligibleForFirstSwapWithMinFee: boolean +): boolean { + return ( + eligibleForFirstSwapWithMinFee && + !hasReferral && + Number(config.enableFirstSwapWithMinFee ?? 0) === 1 && + Number(virtualPool.poolState.hasSwap ?? 0) === 0 + ) +} + +function beginSwapQuote( + virtualPool: VirtualPool, + config: PoolConfig, + swapBaseForQuote: boolean, + amount: BN, + hasReferral: boolean, + transferFees?: QuoteTransferFees +): { + tradeDirection: TradeDirection + feeMode: FeeMode + input: ReturnType['input'] + output: ReturnType['output'] +} { + if ( + virtualPool.poolState.quoteReserve.gte(config.migrationQuoteThreshold) + ) { + throw new Error('Virtual pool is completed') + } + + if (amount.isZero()) { + throw new Error('Amount is zero') + } + + const tradeDirection = swapBaseForQuote + ? TradeDirection.BaseToQuote + : TradeDirection.QuoteToBase + + return { + tradeDirection, + feeMode: getFeeMode(config.collectFeeMode, tradeDirection, hasReferral), + ...resolveSwapTransferFees(config, swapBaseForQuote, transferFees), + } +} // SwapQuote V1 // @@ -106,7 +197,7 @@ export function getSwapResult( const { outputAmount, nextSqrtPrice, amountLeft } = swapAmountFromInput if (!amountLeft.isZero()) { - throw new Error('Insufficient Liquidity') + throw new Error('Liquidity in bonding curve is insufficient') } const actualAmountOut = feeMode.feesOnInput @@ -155,55 +246,52 @@ export function swapQuote( slippageBps: number = 0, hasReferral: boolean, currentPoint: BN, - eligibleForFirstSwapWithMinFee: boolean + eligibleForFirstSwapWithMinFee: boolean, + transferFees?: QuoteTransferFees ): SwapQuoteResult { - if ( - virtualPool.poolState.quoteReserve.gte(config.migrationQuoteThreshold) - ) { - throw new Error('Virtual pool is completed') - } - - if (amountIn.isZero()) { + const { tradeDirection, feeMode, input, output } = beginSwapQuote( + virtualPool, + config, + swapBaseForQuote, + amountIn, + hasReferral, + transferFees + ) + const excludedAmountIn = calculateTransferFeeExcludedAmount( + input, + amountIn + ).amount + if (excludedAmountIn.isZero()) { throw new Error('Amount is zero') } - const tradeDirection = swapBaseForQuote - ? TradeDirection.BaseToQuote - : TradeDirection.QuoteToBase - - const feeMode = getFeeMode( - config.collectFeeMode, - tradeDirection, - hasReferral - ) - const result = getSwapResult( virtualPool, config, - amountIn, + excludedAmountIn, feeMode, tradeDirection, currentPoint, - eligibleForFirstSwapWithMinFee + isFirstSwapWithMinFee( + virtualPool, + config, + hasReferral, + eligibleForFirstSwapWithMinFee + ) ) - - let minimumAmountOut: BN - if (slippageBps > 0) { - // slippage factor: (10000 - slippageBps) / 10000 - const slippageFactor = new BN(10000 - slippageBps) - const denominator = new BN(10000) - - // minimum amount out: amountOut * (10000 - slippageBps) / 10000 - minimumAmountOut = result.outputAmount - .mul(slippageFactor) - .div(denominator) - } else { - minimumAmountOut = result.outputAmount - } + const excludedAmountOut = calculateTransferFeeExcludedAmount( + output, + result.outputAmount + ).amount return { ...result, - minimumAmountOut, + minimumAmountOut: minimumAmountOutWithSlippage( + excludedAmountOut, + slippageBps + ), + includedTransferFeeAmountIn: amountIn, + excludedTransferFeeAmountOut: excludedAmountOut, } } @@ -286,7 +374,7 @@ export function getSwapResultFromExactInput( const { outputAmount, nextSqrtPrice, amountLeft } = swapAmountFromInput if (!amountLeft.isZero()) { - throw new Error('Insufficient Liquidity') + throw new Error('Liquidity in bonding curve is insufficient') } const actualAmountOut = feeMode.feesOnInput @@ -497,7 +585,7 @@ export function calculateBaseToQuoteFromAmountIn( } if (configState.curve[i].sqrtPrice.lt(currentSqrtPriceLocal)) { - const maxAmountIn = getDeltaAmountBaseUnsigned( + const maxAmountIn = getDeltaAmountBaseUnsigned256( configState.curve[i].sqrtPrice, currentSqrtPriceLocal, configState.curve[i + 1].liquidity, @@ -519,10 +607,7 @@ export function calculateBaseToQuoteFromAmountIn( Rounding.Down ) - totalOutputAmount = SafeMath.add( - totalOutputAmount, - outputAmount - ) + totalOutputAmount = addU64(totalOutputAmount, outputAmount) currentSqrtPriceLocal = nextSqrtPrice amountLeft = new BN(0) break @@ -535,12 +620,9 @@ export function calculateBaseToQuoteFromAmountIn( Rounding.Down ) - totalOutputAmount = SafeMath.add( - totalOutputAmount, - outputAmount - ) + totalOutputAmount = addU64(totalOutputAmount, outputAmount) currentSqrtPriceLocal = nextSqrtPrice - amountLeft = SafeMath.sub(amountLeft, maxAmountIn) + amountLeft = SafeMath.sub(amountLeft, u64(maxAmountIn)) } } } @@ -574,7 +656,7 @@ export function calculateBaseToQuoteFromAmountIn( Rounding.Down ) - totalOutputAmount = SafeMath.add(totalOutputAmount, outputAmount) + totalOutputAmount = addU64(totalOutputAmount, outputAmount) currentSqrtPriceLocal = nextSqrtPrice } @@ -631,7 +713,7 @@ export function calculateQuoteToBaseFromAmountIn( ) if (referenceSqrtPrice.gt(currentSqrtPriceLocal)) { - const maxAmountIn = getDeltaAmountQuoteUnsigned( + const maxAmountIn = getDeltaAmountQuoteUnsigned256( currentSqrtPriceLocal, referenceSqrtPrice, configState.curve[i].liquidity, @@ -653,10 +735,7 @@ export function calculateQuoteToBaseFromAmountIn( Rounding.Down ) - totalOutputAmount = SafeMath.add( - totalOutputAmount, - outputAmount - ) + totalOutputAmount = addU64(totalOutputAmount, outputAmount) currentSqrtPriceLocal = nextSqrtPrice amountLeft = new BN(0) break @@ -669,12 +748,9 @@ export function calculateQuoteToBaseFromAmountIn( Rounding.Down ) - totalOutputAmount = SafeMath.add( - totalOutputAmount, - outputAmount - ) + totalOutputAmount = addU64(totalOutputAmount, outputAmount) currentSqrtPriceLocal = nextSqrtPrice - amountLeft = SafeMath.sub(amountLeft, maxAmountIn) + amountLeft = SafeMath.sub(amountLeft, u64(maxAmountIn)) if (nextSqrtPrice.eq(stopSqrtPrice)) { break @@ -773,7 +849,7 @@ export function getSwapResultFromExactOutput( const { outputAmount: amountIn, nextSqrtPrice } = swapAmountFromOutput if (nextSqrtPrice.gt(config.migrationSqrtPrice)) { - throw new Error('Insufficient Liquidity') + throw new Error('Liquidity in bonding curve is insufficient') } const [excludedFeeInputAmount, includedFeeInputAmount] = feeMode.feesOnInput @@ -846,7 +922,7 @@ export function calculateBaseToQuoteFromAmountOut( } if (configState.curve[i].sqrtPrice.lt(currentSqrtPriceLocal)) { - const maxAmountOut = getDeltaAmountQuoteUnsigned( + const maxAmountOut = getDeltaAmountQuoteUnsigned256( configState.curve[i].sqrtPrice, currentSqrtPriceLocal, configState.curve[i + 1].liquidity, @@ -868,7 +944,7 @@ export function calculateBaseToQuoteFromAmountOut( Rounding.Up ) - totalAmountIn = SafeMath.add(totalAmountIn, inAmount) + totalAmountIn = addU64(totalAmountIn, inAmount) currentSqrtPriceLocal = nextSqrtPrice amountLeft = new BN(0) break @@ -881,15 +957,15 @@ export function calculateBaseToQuoteFromAmountOut( Rounding.Up ) - totalAmountIn = SafeMath.add(totalAmountIn, inAmount) + totalAmountIn = addU64(totalAmountIn, inAmount) currentSqrtPriceLocal = nextSqrtPrice - amountLeft = SafeMath.sub(amountLeft, maxAmountOut) + amountLeft = SafeMath.sub(amountLeft, u64(maxAmountOut)) } } } if (!amountLeft.isZero()) { - const maxAmountOut = getDeltaAmountQuoteUnsigned( + const maxAmountOut = getDeltaAmountQuoteUnsigned256( configState.sqrtStartPrice, currentSqrtPriceLocal, configState.curve[0].liquidity, @@ -897,7 +973,7 @@ export function calculateBaseToQuoteFromAmountOut( ) if (amountLeft.gt(maxAmountOut)) { - throw new Error('Insufficient Liquidity') + throw new Error('Liquidity in bonding curve is insufficient') } const nextSqrtPrice = getNextSqrtPriceFromOutput( @@ -908,7 +984,7 @@ export function calculateBaseToQuoteFromAmountOut( ) if (nextSqrtPrice.lt(configState.sqrtStartPrice)) { - throw new Error('Insufficient Liquidity') + throw new Error('Liquidity in bonding curve is insufficient') } const inAmount = getDeltaAmountBaseUnsigned( @@ -918,7 +994,7 @@ export function calculateBaseToQuoteFromAmountOut( Rounding.Up ) - totalAmountIn = SafeMath.add(totalAmountIn, inAmount) + totalAmountIn = addU64(totalAmountIn, inAmount) currentSqrtPriceLocal = nextSqrtPrice } @@ -955,7 +1031,7 @@ export function calculateQuoteToBaseFromAmountOut( } if (configState.curve[i].sqrtPrice.gt(currentSqrtPriceLocal)) { - const maxAmountOut = getDeltaAmountBaseUnsigned( + const maxAmountOut = getDeltaAmountBaseUnsigned256( currentSqrtPriceLocal, configState.curve[i].sqrtPrice, configState.curve[i].liquidity, @@ -977,7 +1053,7 @@ export function calculateQuoteToBaseFromAmountOut( Rounding.Up ) - totalInAmount = SafeMath.add(totalInAmount, inAmount) + totalInAmount = addU64(totalInAmount, inAmount) currentSqrtPriceLocal = nextSqrtPrice amountLeft = new BN(0) break @@ -990,15 +1066,15 @@ export function calculateQuoteToBaseFromAmountOut( Rounding.Up ) - totalInAmount = SafeMath.add(totalInAmount, inAmount) + totalInAmount = addU64(totalInAmount, inAmount) currentSqrtPriceLocal = nextSqrtPrice - amountLeft = SafeMath.sub(amountLeft, maxAmountOut) + amountLeft = SafeMath.sub(amountLeft, u64(maxAmountOut)) } } } if (!amountLeft.isZero()) { - throw new Error('Not enough liquidity') + throw new Error('Amount left is not zero') } return { @@ -1028,56 +1104,52 @@ export function swapQuoteExactIn( slippageBps: number = 0, hasReferral: boolean, currentPoint: BN, - eligibleForFirstSwapWithMinFee: boolean + eligibleForFirstSwapWithMinFee: boolean, + transferFees?: QuoteTransferFees ): SwapQuote2Result { - if ( - virtualPool.poolState.quoteReserve.gte(config.migrationQuoteThreshold) - ) { - throw new Error('Virtual pool is completed') - } - - if (amountIn.isZero()) { + const { tradeDirection, feeMode, input, output } = beginSwapQuote( + virtualPool, + config, + swapBaseForQuote, + amountIn, + hasReferral, + transferFees + ) + const excludedAmountIn = calculateTransferFeeExcludedAmount( + input, + amountIn + ).amount + if (excludedAmountIn.isZero()) { throw new Error('Amount is zero') } - const tradeDirection = swapBaseForQuote - ? TradeDirection.BaseToQuote - : TradeDirection.QuoteToBase - - const feeMode = getFeeMode( - config.collectFeeMode, - tradeDirection, - hasReferral - ) - const result = getSwapResultFromExactInput( virtualPool, config, - amountIn, + excludedAmountIn, feeMode, tradeDirection, currentPoint, - eligibleForFirstSwapWithMinFee + isFirstSwapWithMinFee( + virtualPool, + config, + hasReferral, + eligibleForFirstSwapWithMinFee + ) ) - - // calculate minimum amount out - let minimumAmountOut: BN - if (slippageBps > 0) { - // slippage factor: (10000 - slippageBps) / 10000 - const slippageFactor = new BN(10000 - slippageBps) - const denominator = new BN(10000) - - // minimum amount out: amountOut * (10000 - slippageBps) / 10000 - minimumAmountOut = result.outputAmount - .mul(slippageFactor) - .div(denominator) - } else { - minimumAmountOut = result.outputAmount - } + const excludedAmountOut = calculateTransferFeeExcludedAmount( + output, + result.outputAmount + ).amount return { ...result, - minimumAmountOut, + minimumAmountOut: minimumAmountOutWithSlippage( + excludedAmountOut, + slippageBps + ), + includedTransferFeeAmountIn: amountIn, + excludedTransferFeeAmountOut: excludedAmountOut, } } @@ -1100,56 +1172,58 @@ export function swapQuotePartialFill( slippageBps: number = 0, hasReferral: boolean, currentPoint: BN, - eligibleForFirstSwapWithMinFee: boolean + eligibleForFirstSwapWithMinFee: boolean, + transferFees?: QuoteTransferFees ): SwapQuote2Result { - if ( - virtualPool.poolState.quoteReserve.gte(config.migrationQuoteThreshold) - ) { - throw new Error('Virtual pool is completed') - } - - if (amountIn.isZero()) { + const { tradeDirection, feeMode, input, output } = beginSwapQuote( + virtualPool, + config, + swapBaseForQuote, + amountIn, + hasReferral, + transferFees + ) + const excludedAmountIn = calculateTransferFeeExcludedAmount( + input, + amountIn + ).amount + if (excludedAmountIn.isZero()) { throw new Error('Amount is zero') } - const tradeDirection = swapBaseForQuote - ? TradeDirection.BaseToQuote - : TradeDirection.QuoteToBase - - const feeMode = getFeeMode( - config.collectFeeMode, - tradeDirection, - hasReferral - ) - const result = getSwapResultFromPartialInput( virtualPool, config, - amountIn, + excludedAmountIn, feeMode, tradeDirection, currentPoint, - eligibleForFirstSwapWithMinFee + isFirstSwapWithMinFee( + virtualPool, + config, + hasReferral, + eligibleForFirstSwapWithMinFee + ) ) - - // calculate minimum amount out - let minimumAmountOut: BN - if (slippageBps > 0) { - // slippage factor: (10000 - slippageBps) / 10000 - const slippageFactor = new BN(10000 - slippageBps) - const denominator = new BN(10000) - - // minimum amount out: amountOut * (10000 - slippageBps) / 10000 - minimumAmountOut = result.outputAmount - .mul(slippageFactor) - .div(denominator) - } else { - minimumAmountOut = result.outputAmount - } + const includedAmountIn = result.amountLeft.isZero() + ? amountIn + : calculateTransferFeeIncludedAmount( + input, + result.includedFeeInputAmount + ).amount + const excludedAmountOut = calculateTransferFeeExcludedAmount( + output, + result.outputAmount + ).amount return { ...result, - minimumAmountOut, + minimumAmountOut: minimumAmountOutWithSlippage( + excludedAmountOut, + slippageBps + ), + includedTransferFeeAmountIn: includedAmountIn, + excludedTransferFeeAmountOut: excludedAmountOut, } } @@ -1173,55 +1247,51 @@ export function swapQuoteExactOut( slippageBps: number = 0, hasReferral: boolean, currentPoint: BN, - eligibleForFirstSwapWithMinFee: boolean + eligibleForFirstSwapWithMinFee: boolean, + transferFees?: QuoteTransferFees ): SwapQuote2Result { - if ( - virtualPool.poolState.quoteReserve.gte(config.migrationQuoteThreshold) - ) { - throw new Error('Virtual pool is completed') - } - - if (outAmount.isZero()) { + const { tradeDirection, feeMode, input, output } = beginSwapQuote( + virtualPool, + config, + swapBaseForQuote, + outAmount, + hasReferral, + transferFees + ) + const includedAmountOut = calculateTransferFeeIncludedAmount( + output, + outAmount + ).amount + if (includedAmountOut.isZero()) { throw new Error('Amount is zero') } - const tradeDirection = swapBaseForQuote - ? TradeDirection.BaseToQuote - : TradeDirection.QuoteToBase - - const feeMode = getFeeMode( - config.collectFeeMode, - tradeDirection, - hasReferral - ) - const result = getSwapResultFromExactOutput( virtualPool, config, - outAmount, + includedAmountOut, feeMode, tradeDirection, currentPoint, - eligibleForFirstSwapWithMinFee + isFirstSwapWithMinFee( + virtualPool, + config, + hasReferral, + eligibleForFirstSwapWithMinFee + ) ) - - // calculate maximum amount in (for slippage protection) - let maximumAmountIn: BN - if (slippageBps > 0) { - // slippage factor: (10000 + slippageBps) / 10000 - const slippageFactor = new BN(10000 + slippageBps) - const denominator = new BN(10000) - - // maximum amount in: inputAmount * (10000 + slippageBps) / 10000 - maximumAmountIn = result.includedFeeInputAmount - .mul(slippageFactor) - .div(denominator) - } else { - maximumAmountIn = result.includedFeeInputAmount - } + const includedAmountIn = calculateTransferFeeIncludedAmount( + input, + result.includedFeeInputAmount + ).amount return { ...result, - maximumAmountIn, + maximumAmountIn: maximumAmountInWithSlippage( + includedAmountIn, + slippageBps + ), + includedTransferFeeAmountIn: includedAmountIn, + excludedTransferFeeAmountOut: outAmount, } } diff --git a/packages/dynamic-bonding-curve/src/math/transferFee.ts b/packages/dynamic-bonding-curve/src/math/transferFee.ts new file mode 100644 index 00000000..b62b0a6c --- /dev/null +++ b/packages/dynamic-bonding-curve/src/math/transferFee.ts @@ -0,0 +1,192 @@ +import { + calculateFee, + getEpochFee, + getTransferFeeConfig, + MAX_FEE_BASIS_POINTS, + type Mint, +} from '@solana/spl-token' +import BN from 'bn.js' +import { U64_MAX } from '../constants' +import { type PoolConfig, type QuoteTransferFees, TokenType } from '../types' + +export type EpochTransferFee = { + transferFeeBasisPoints: number + maximumFee: BN +} + +export type TransferFeeAmount = { + amount: BN + transferFee: BN +} + +const ZERO = new BN(0) + +function toSplTransferFee(transferFee: EpochTransferFee) { + return { + epoch: BigInt(0), + maximumFee: BigInt(transferFee.maximumFee.toString()), + transferFeeBasisPoints: transferFee.transferFeeBasisPoints, + } +} + +/** + * Return the base mint transfer fee a config creates. The program always sets the maximum fee to u64::MAX. + */ +export function getBaseTransferFee( + transferFeeBasisPoints: number +): EpochTransferFee | null { + if (!transferFeeBasisPoints) { + return null + } + return { + transferFeeBasisPoints, + maximumFee: U64_MAX, + } +} + +export function getEpochTransferFee( + mint: Mint, + currentEpoch: number +): EpochTransferFee | null { + const transferFeeConfig = getTransferFeeConfig(mint) + if (!transferFeeConfig) { + return null + } + const epochFee = getEpochFee(transferFeeConfig, BigInt(currentEpoch)) + return { + transferFeeBasisPoints: epochFee.transferFeeBasisPoints, + maximumFee: new BN(epochFee.maximumFee.toString()), + } +} + +export function resolveSwapTransferFees( + config: PoolConfig, + swapBaseForQuote: boolean, + transferFees?: QuoteTransferFees +): { input: EpochTransferFee | null; output: EpochTransferFee | null } { + let base: EpochTransferFee | null + if (transferFees?.baseMint) { + if (transferFees.currentEpoch === undefined) { + throw new Error('currentEpoch is required to quote a transfer fee') + } + base = getEpochTransferFee( + transferFees.baseMint, + transferFees.currentEpoch + ) + } else if (transferFees?.baseTransferFeeBasisPoints !== undefined) { + base = getBaseTransferFee(transferFees.baseTransferFeeBasisPoints) + } else { + base = getBaseTransferFee(config.transferFeeBasisPoints ?? 0) + } + + let quote: EpochTransferFee | null = null + if (transferFees?.quoteMint) { + if ( + config.quoteMint && + !transferFees.quoteMint.address.equals(config.quoteMint) + ) { + throw new Error('quoteMint does not match config.quoteMint') + } + if (transferFees.currentEpoch === undefined) { + throw new Error('currentEpoch is required to quote a transfer fee') + } + quote = getEpochTransferFee( + transferFees.quoteMint, + transferFees.currentEpoch + ) + } else if (Number(config.quoteTokenFlag) === TokenType.Token2022) { + // the program applies the quote mint's current transfer fee, which only the mint account holds + throw new Error( + 'quoteMint and currentEpoch are required for a Token-2022 quote mint, use getSwapQuoteTransferFees' + ) + } + + if (swapBaseForQuote) { + return { input: base, output: quote } + } + return { input: quote, output: base } +} + +export function calculateTransferFeeExcludedAmount( + transferFee: EpochTransferFee | null, + includedAmount: BN +): TransferFeeAmount { + if ( + !transferFee || + transferFee.transferFeeBasisPoints === 0 || + includedAmount.isZero() + ) { + return { amount: includedAmount, transferFee: ZERO } + } + + const fee = new BN( + calculateFee( + toSplTransferFee(transferFee), + BigInt(includedAmount.toString()) + ).toString() + ) + return { + amount: includedAmount.sub(fee), + transferFee: fee, + } +} + +function calculatePreFeeAmount( + transferFee: EpochTransferFee, + excludedAmount: BN +): BN { + if (excludedAmount.isZero() || transferFee.transferFeeBasisPoints === 0) { + return excludedAmount + } + + if (transferFee.transferFeeBasisPoints === MAX_FEE_BASIS_POINTS) { + return excludedAmount.add(transferFee.maximumFee) + } + + const oneInBasisPoints = new BN(MAX_FEE_BASIS_POINTS) + const denominator = oneInBasisPoints.sub( + new BN(transferFee.transferFeeBasisPoints) + ) + const rawPreFeeAmount = excludedAmount + .mul(oneInBasisPoints) + .add(denominator) + .sub(new BN(1)) + .div(denominator) + + if (rawPreFeeAmount.sub(excludedAmount).gte(transferFee.maximumFee)) { + return excludedAmount.add(transferFee.maximumFee) + } + + return rawPreFeeAmount +} + +export function calculateTransferFeeIncludedAmount( + transferFee: EpochTransferFee | null, + excludedAmount: BN +): TransferFeeAmount { + if (excludedAmount.isZero()) { + return { amount: ZERO, transferFee: ZERO } + } + if (!transferFee || transferFee.transferFeeBasisPoints === 0) { + return { amount: excludedAmount, transferFee: ZERO } + } + + const preFeeAmount = calculatePreFeeAmount(transferFee, excludedAmount) + if (preFeeAmount.gt(U64_MAX)) { + throw new Error('Math overflow') + } + const fee = calculateTransferFeeExcludedAmount( + transferFee, + preFeeAmount + ).transferFee + const includedAmount = excludedAmount.add(fee) + const verification = calculateTransferFeeExcludedAmount( + transferFee, + includedAmount + ).transferFee + if (!fee.eq(verification)) { + throw new Error('Fee inverse is incorrect') + } + + return { amount: includedAmount, transferFee: fee } +} diff --git a/packages/dynamic-bonding-curve/src/math/utilsMath.ts b/packages/dynamic-bonding-curve/src/math/utilsMath.ts index a1531c8a..d8b2a1c9 100644 --- a/packages/dynamic-bonding-curve/src/math/utilsMath.ts +++ b/packages/dynamic-bonding-curve/src/math/utilsMath.ts @@ -1,6 +1,7 @@ import BN from 'bn.js' import { SafeMath } from './safeMath' import { Rounding } from '../types' +import { MAX_BASIS_POINT } from '../constants' /** * Multiply and divide with rounding using BN @@ -31,6 +32,29 @@ export function mulDiv(x: BN, y: BN, denominator: BN, rounding: Rounding): BN { } } +/** + * Convert basis points to a fee numerator. + * @param bps - Basis points + * @param feeDenominator - Fee denominator + * @returns Fee numerator + * @throws If the calculation overflows + */ +export function toNumerator(bps: BN, feeDenominator: BN): BN { + try { + const numerator = mulDiv( + bps, + feeDenominator, + new BN(MAX_BASIS_POINT), + Rounding.Down + ) + return numerator + } catch (error) { + throw new Error( + `Type cast failed or calculation overflow in toNumerator ${error}` + ) + } +} + /** * Multiply and shift right with BN * @param x First number diff --git a/packages/dynamic-bonding-curve/src/services/creator.ts b/packages/dynamic-bonding-curve/src/services/creator.ts index e46de8b0..6df0e21e 100644 --- a/packages/dynamic-bonding-curve/src/services/creator.ts +++ b/packages/dynamic-bonding-curve/src/services/creator.ts @@ -1,9 +1,11 @@ import { + PublicKey, SystemProgram, Transaction, TransactionInstruction, } from '@solana/web3.js' import { + AccountsType, ClaimCreatorTradingFeeParams, ClaimCreatorTradingFeeToReceiverParams, CreatePoolParams, @@ -26,24 +28,91 @@ import { getTokenProgram, isNativeSol, unwrapSOLInstruction, + resolveTempWsolAccount, assertConfigAllowsNewPool, + hasMintAuthority, + validateMinimumLockedLiquidity, } from '../helpers' +import { getFeeSchedulerMinBaseFeeNumerator } from '../math' +import { MIN_FEE_NUMERATOR, MIN_LOCKED_LIQUIDITY_BPS } from '../constants' import BN from 'bn.js' export class CreatorService extends DynamicBondingCurveProgram { private async getPoolConfigForNewPool( - config: CreatePoolParams['config'] + config: CreatePoolParams['config'], + transferHookProgram?: PublicKey ): Promise { - const poolConfigState = await this.state.getPoolConfig(config) + const isTransferHookConfig = + await this.state.isTransferHookConfig(config) + const configWithTransferHook = isTransferHookConfig + ? await this.state.getConfigWithTransferHook(config) + : null + const poolConfigState = isTransferHookConfig + ? (configWithTransferHook?.config ?? null) + : await this.state.getPoolConfig(config) if (!poolConfigState) { throw new Error(`Pool config not found for virtual pool`) } + if (isTransferHookConfig !== (transferHookProgram !== undefined)) { + throw new Error( + isTransferHookConfig + ? 'Config uses a transfer hook, create the pool with the transfer-hook method' + : 'Config does not use a transfer hook, create the pool with the standard method' + ) + } + if ( + configWithTransferHook && + transferHookProgram && + !configWithTransferHook.transferHookProgram.equals( + transferHookProgram + ) + ) { + throw new Error( + `Transfer hook program does not match config: expected ${configWithTransferHook.transferHookProgram.toBase58()}` + ) + } + assertConfigAllowsNewPool({ baseFeeMode: poolConfigState.poolFees.baseFee.baseFeeMode, migrationOption: poolConfigState.migrationOption, }) + if ( + !isTransferHookConfig && + hasMintAuthority(poolConfigState.tokenUpdateAuthority) + ) { + throw new Error( + 'Mint authority token update options are only supported for transfer-hook configs' + ) + } + + if ( + !validateMinimumLockedLiquidity( + poolConfigState.partnerPermanentLockedLiquidityPercentage, + poolConfigState.creatorPermanentLockedLiquidityPercentage, + poolConfigState.partnerLiquidityVestingInfo, + poolConfigState.creatorLiquidityVestingInfo + ) + ) { + throw new Error( + `Config must lock at least ${MIN_LOCKED_LIQUIDITY_BPS} BPS of migrated liquidity at day 1` + ) + } + + const baseFee = poolConfigState.poolFees.baseFee + const minBaseFeeNumerator = getFeeSchedulerMinBaseFeeNumerator( + baseFee.cliffFeeNumerator, + baseFee.firstFactor, + baseFee.thirdFactor, + baseFee.baseFeeMode + ) + if (minBaseFeeNumerator.lt(new BN(MIN_FEE_NUMERATOR))) { + throw new Error( + `Config minimum base fee numerator ${minBaseFeeNumerator.toString()} is below ${MIN_FEE_NUMERATOR}` + ) + } + return poolConfigState } @@ -95,9 +164,12 @@ export class CreatorService extends DynamicBondingCurveProgram { async createPoolWithTransferHook( params: CreatePoolWithTransferHookParams ): Promise { - const { config } = params + const { config, transferHookProgram } = params - const poolConfigState = await this.getPoolConfigForNewPool(config) + const poolConfigState = await this.getPoolConfigForNewPool( + config, + transferHookProgram + ) const tokenQuoteProgram = getTokenProgram( poolConfigState.quoteTokenFlag @@ -154,9 +226,12 @@ export class CreatorService extends DynamicBondingCurveProgram { params: CreatePoolWithFirstBuyWithTransferHookParams ): Promise { const { createPoolParam, firstBuyParam } = params - const { config } = createPoolParam + const { config, transferHookProgram } = createPoolParam - const poolConfigState = await this.getPoolConfigForNewPool(config) + const poolConfigState = await this.getPoolConfigForNewPool( + config, + transferHookProgram + ) const createPoolWithFirstBuyTx = await this.buildCreatePoolWithTransferHookTx( @@ -172,6 +247,7 @@ export class CreatorService extends DynamicBondingCurveProgram { config, poolConfigState.poolFees.baseFee, poolConfigState.activationType, + poolConfigState.collectFeeMode, poolConfigState.quoteMint, true ) @@ -260,9 +336,12 @@ export class CreatorService extends DynamicBondingCurveProgram { ): Promise { const { createPoolParam, partnerFirstBuyParam, creatorFirstBuyParam } = params - const { config } = createPoolParam + const { config, transferHookProgram } = createPoolParam - const poolConfigState = await this.getPoolConfigForNewPool(config) + const poolConfigState = await this.getPoolConfigForNewPool( + config, + transferHookProgram + ) const createPoolWithFirstBuysTx = await this.buildCreatePoolWithTransferHookTx( @@ -292,6 +371,7 @@ export class CreatorService extends DynamicBondingCurveProgram { config, poolConfigState.poolFees.baseFee, poolConfigState.activationType, + poolConfigState.collectFeeMode, poolConfigState.quoteMint, true ) @@ -319,6 +399,7 @@ export class CreatorService extends DynamicBondingCurveProgram { config, poolConfigState.poolFees.baseFee, poolConfigState.activationType, + poolConfigState.collectFeeMode, poolConfigState.quoteMint, true ) @@ -347,60 +428,38 @@ export class CreatorService extends DynamicBondingCurveProgram { tempWSolAcc, } = params - const { virtualPool, poolConfigState } = + const { virtualPool, poolConfigState, isTransferHookPool } = await this.getPoolWithConfig(pool) + if (isTransferHookPool) { + throw new Error( + 'Pool uses a transfer hook, use claimCreatorTradingFee2' + ) + } const tokenBaseProgram = getTokenProgram(poolConfigState.tokenType) const tokenQuoteProgram = getTokenProgram( poolConfigState.quoteTokenFlag ) - const isSOLQuoteMint = isNativeSol(poolConfigState.quoteMint) - - if (isSOLQuoteMint) { - // if receiver is present and not equal to creator, use tempWSolAcc, otherwise use creator - const tempWSol = - receiver && !receiver.equals(creator) ? tempWSolAcc : creator - // if receiver is provided, use receiver, otherwise use creator - const feeReceiver = receiver ? receiver : creator - - const result = await this.buildClaimTradingFeeAccountsForSol({ - payer, - feeReceiver, - tempWSolAcc: tempWSol, - pool, - virtualPool, - poolConfigState, - tokenBaseProgram, - tokenQuoteProgram, - }) + const feeReceiver = receiver ? receiver : creator + const tempWSol = resolveTempWsolAccount(creator, receiver, tempWSolAcc) + const result = await this.resolveTradingFeeAccounts({ + payer, + feeReceiver, + tempWSolAcc: tempWSol, + pool, + virtualPool, + poolConfigState, + tokenBaseProgram, + tokenQuoteProgram, + }) - return this.program.methods - .claimCreatorTradingFee(maxBaseAmount, maxQuoteAmount) - .accountsPartial({ ...result.accounts, creator }) - .preInstructions(result.preInstructions) - .postInstructions(result.postInstructions) - .transaction() - } else { - // check if receiver is provided, use receiver, otherwise use creator - const feeReceiver = receiver ? receiver : creator - - const result = await this.buildClaimTradingFeeAccountsForNonSol({ - payer, - feeReceiver, - pool, - virtualPool, - poolConfigState, - tokenBaseProgram, - tokenQuoteProgram, - }) - return this.program.methods - .claimCreatorTradingFee(maxBaseAmount, maxQuoteAmount) - .accountsPartial({ ...result.accounts, creator }) - .preInstructions(result.preInstructions) - .postInstructions([]) - .transaction() - } + return this.program.methods + .claimCreatorTradingFee(maxBaseAmount, maxQuoteAmount) + .accountsPartial({ ...result.accounts, creator }) + .preInstructions(result.preInstructions) + .postInstructions(result.postInstructions) + .transaction() } /** @@ -418,51 +477,36 @@ export class CreatorService extends DynamicBondingCurveProgram { payer, } = params - const { virtualPool, poolConfigState } = + const { virtualPool, poolConfigState, isTransferHookPool } = await this.getPoolWithConfig(pool) + if (isTransferHookPool) { + throw new Error( + 'Pool uses a transfer hook, use claimCreatorTradingFee2' + ) + } const tokenBaseProgram = getTokenProgram(poolConfigState.tokenType) const tokenQuoteProgram = getTokenProgram( poolConfigState.quoteTokenFlag ) - const isSOLQuoteMint = isNativeSol(poolConfigState.quoteMint) - - if (isSOLQuoteMint) { - const result = await this.buildClaimTradingFeeAccountsForSol({ - payer, - feeReceiver: receiver, - tempWSolAcc: creator, - pool, - virtualPool, - poolConfigState, - tokenBaseProgram, - tokenQuoteProgram, - }) + const result = await this.resolveTradingFeeAccounts({ + payer, + feeReceiver: receiver, + tempWSolAcc: creator, + pool, + virtualPool, + poolConfigState, + tokenBaseProgram, + tokenQuoteProgram, + }) - return this.program.methods - .claimCreatorTradingFee(maxBaseAmount, maxQuoteAmount) - .accountsPartial({ ...result.accounts, creator }) - .preInstructions(result.preInstructions) - .postInstructions(result.postInstructions) - .transaction() - } else { - const result = await this.buildClaimTradingFeeAccountsForNonSol({ - payer, - feeReceiver: receiver, - pool, - virtualPool, - poolConfigState, - tokenBaseProgram, - tokenQuoteProgram, - }) - return this.program.methods - .claimCreatorTradingFee(maxBaseAmount, maxQuoteAmount) - .accountsPartial({ ...result.accounts, creator }) - .preInstructions(result.preInstructions) - .postInstructions([]) - .transaction() - } + return this.program.methods + .claimCreatorTradingFee(maxBaseAmount, maxQuoteAmount) + .accountsPartial({ ...result.accounts, creator }) + .preInstructions(result.preInstructions) + .postInstructions(result.postInstructions) + .transaction() } /** @@ -488,38 +532,32 @@ export class CreatorService extends DynamicBondingCurveProgram { poolConfigState.quoteTokenFlag ) - const isSOLQuoteMint = isNativeSol(poolConfigState.quoteMint) - const result = isSOLQuoteMint - ? await this.buildClaimTradingFeeAccountsForSol({ - payer, - feeReceiver: receiver, - tempWSolAcc: creator, - pool, - virtualPool, - poolConfigState, - tokenBaseProgram, - tokenQuoteProgram, - }) - : await this.buildClaimTradingFeeAccountsForNonSol({ - payer, - feeReceiver: receiver, - pool, - virtualPool, - poolConfigState, - tokenBaseProgram, - tokenQuoteProgram, - }) + const result = await this.resolveTradingFeeAccounts({ + payer, + feeReceiver: receiver, + tempWSolAcc: creator, + pool, + virtualPool, + poolConfigState, + tokenBaseProgram, + tokenQuoteProgram, + }) const { info: transferHookAccountsInfo, accounts: transferHookAccounts, } = await this.getRemainingAccountsForTransferHook( - virtualPool.poolState.baseMint + virtualPool.poolState.baseMint, + [ + { + accountsType: AccountsType.TransferHookBase, + source: virtualPool.poolState.baseVault, + destination: result.accounts.tokenAAccount, + authority: this.poolAuthority, + }, + ] ) - const postInstructions: TransactionInstruction[] = - isSOLQuoteMint && 'postInstructions' in result - ? (result.postInstructions as TransactionInstruction[]) - : [] + const postInstructions = result.postInstructions return this.program.methods .claimCreatorTradingFee2( diff --git a/packages/dynamic-bonding-curve/src/services/partner.ts b/packages/dynamic-bonding-curve/src/services/partner.ts index a9c1dc9e..5e75f98f 100644 --- a/packages/dynamic-bonding-curve/src/services/partner.ts +++ b/packages/dynamic-bonding-curve/src/services/partner.ts @@ -6,11 +6,13 @@ import { } from '@solana/web3.js' import { DynamicBondingCurveProgram } from './program' import { + AccountsType, type ClaimPartnerTradingFeeParams, type CreateConfigAndPoolWithTransferHookParams, type CreateConfigAndPoolParams, type CreateConfigAndPoolWithFirstBuyWithTransferHookParams, type CreateConfigAndPoolWithFirstBuyParams, + type CreateConfig2Params, type CreateConfigParams, type CreateConfigWithTransferHookParams, type CreatePoolParams, @@ -26,8 +28,9 @@ import { unwrapSOLInstruction, getTokenProgram, getOrCreateATAInstruction, - isNativeSol, getTokenType, + requirePublicKey, + resolveTempWsolAccount, } from '../helpers' import { NATIVE_MINT } from '@solana/spl-token' import BN from 'bn.js' @@ -35,6 +38,7 @@ import BN from 'bn.js' export class PartnerService extends DynamicBondingCurveProgram { /** * Build a transaction that creates a partner-owned pool config. + * @deprecated Use `createConfig2`. This instruction rejects a quote mint with a non-zero transfer fee or a live transfer fee config authority. */ async createConfig(params: CreateConfigParams): Promise { const { @@ -49,11 +53,39 @@ export class PartnerService extends DynamicBondingCurveProgram { return this.buildCreateConfigTx( configParam, - new PublicKey(config), + requirePublicKey(config, 'config'), new PublicKey(feeClaimer), new PublicKey(leftoverReceiver), new PublicKey(quoteMint), - new PublicKey(payer), + requirePublicKey(payer, 'payer'), + tokenBadge ? new PublicKey(tokenBadge) : undefined + ) + } + + /** + * Build a transaction that creates a partner-owned pool config. + * `transferFeeParameters` configures the base mint fee. Pass `null` when only the quote mint has a transfer fee. + */ + async createConfig2(params: CreateConfig2Params): Promise { + const { + config, + feeClaimer, + leftoverReceiver, + quoteMint, + payer, + tokenBadge, + transferFeeParameters = null, + ...configParam + } = params + + return this.buildCreateConfig2Tx( + configParam, + requirePublicKey(config, 'config'), + new PublicKey(feeClaimer), + new PublicKey(leftoverReceiver), + new PublicKey(quoteMint), + requirePublicKey(payer, 'payer'), + transferFeeParameters, tokenBadge ? new PublicKey(tokenBadge) : undefined ) } @@ -77,12 +109,12 @@ export class PartnerService extends DynamicBondingCurveProgram { return this.buildCreateConfigWithTransferHookTx( configParam, - new PublicKey(config), + requirePublicKey(config, 'config'), new PublicKey(feeClaimer), new PublicKey(leftoverReceiver), new PublicKey(quoteMint), new PublicKey(transferHookProgram), - new PublicKey(payer), + requirePublicKey(payer, 'payer'), tokenBadge ? new PublicKey(tokenBadge) : undefined ) } @@ -101,22 +133,24 @@ export class PartnerService extends DynamicBondingCurveProgram { payer, preCreatePoolParam, tokenBadge, + transferFeeParameters = null, ...configParam } = params const tx = new Transaction() - const configKey = new PublicKey(config) + const configKey = requirePublicKey(config, 'config') const quoteMintToken = new PublicKey(quoteMint) - const payerAddress = new PublicKey(payer) + const payerAddress = requirePublicKey(payer, 'payer') const tokenBadgeKey = tokenBadge ? new PublicKey(tokenBadge) : undefined - const createConfigTx = await this.buildCreateConfigTx( + const createConfigTx = await this.buildCreateConfig2Tx( configParam, configKey, new PublicKey(feeClaimer), new PublicKey(leftoverReceiver), quoteMintToken, payerAddress, + transferFeeParameters, tokenBadgeKey ) @@ -154,9 +188,9 @@ export class PartnerService extends DynamicBondingCurveProgram { } = params const tx = new Transaction() - const configKey = new PublicKey(config) + const configKey = requirePublicKey(config, 'config') const quoteMintToken = new PublicKey(quoteMint) - const payerAddress = new PublicKey(payer) + const payerAddress = requirePublicKey(payer, 'payer') const tokenBadgeKey = tokenBadge ? new PublicKey(tokenBadge) : undefined const createConfigTx = await this.buildCreateConfigWithTransferHookTx( @@ -210,21 +244,23 @@ export class PartnerService extends DynamicBondingCurveProgram { preCreatePoolParam, firstBuyParam, tokenBadge, + transferFeeParameters = null, ...configParam } = params - const configKey = new PublicKey(config) + const configKey = requirePublicKey(config, 'config') const quoteMintToken = new PublicKey(quoteMint) - const payerAddress = new PublicKey(payer) + const payerAddress = requirePublicKey(payer, 'payer') const tokenBadgeKey = tokenBadge ? new PublicKey(tokenBadge) : undefined - const createConfigTx = await this.buildCreateConfigTx( + const createConfigTx = await this.buildCreateConfig2Tx( configParam, configKey, new PublicKey(feeClaimer), new PublicKey(leftoverReceiver), quoteMintToken, payerAddress, + transferFeeParameters, tokenBadgeKey ) @@ -284,9 +320,9 @@ export class PartnerService extends DynamicBondingCurveProgram { ...configParam } = params - const configKey = new PublicKey(config) + const configKey = requirePublicKey(config, 'config') const quoteMintToken = new PublicKey(quoteMint) - const payerAddress = new PublicKey(payer) + const payerAddress = requirePublicKey(payer, 'payer') const transferHookProgramKey = new PublicKey(transferHookProgram) const tokenBadgeKey = tokenBadge ? new PublicKey(tokenBadge) : undefined @@ -325,6 +361,7 @@ export class PartnerService extends DynamicBondingCurveProgram { configKey, configParam.poolFees.baseFee, configParam.activationType, + configParam.collectFeeMode, quoteMintToken, true ) @@ -384,69 +421,46 @@ export class PartnerService extends DynamicBondingCurveProgram { tempWSolAcc, } = params - const { virtualPool, poolConfigState } = + const { virtualPool, poolConfigState, isTransferHookPool } = await this.getPoolWithConfig(pool) + if (isTransferHookPool) { + throw new Error( + 'Pool uses a transfer hook, use claimPartnerTradingFee2' + ) + } const tokenBaseProgram = getTokenProgram(poolConfigState.tokenType) const tokenQuoteProgram = getTokenProgram( poolConfigState.quoteTokenFlag ) - const isSOLQuoteMint = isNativeSol(poolConfigState.quoteMint) - - if (isSOLQuoteMint) { - // if receiver is present and not equal to feeClaimer, use tempWSolAcc, otherwise use feeClaimer - const tempWSol = - receiver && !receiver.equals(feeClaimer) - ? tempWSolAcc - : feeClaimer - // if receiver is provided, use receiver as the fee receiver, otherwise use feeClaimer - const feeReceiver = receiver ? receiver : feeClaimer - - const result = await this.buildClaimTradingFeeAccountsForSol({ - payer, - feeReceiver, - tempWSolAcc: tempWSol, - pool, - virtualPool, - poolConfigState, - tokenBaseProgram, - tokenQuoteProgram, - }) + const feeReceiver = receiver ? receiver : feeClaimer + const tempWSol = resolveTempWsolAccount( + feeClaimer, + receiver, + tempWSolAcc + ) + const result = await this.resolveTradingFeeAccounts({ + payer, + feeReceiver, + tempWSolAcc: tempWSol, + pool, + virtualPool, + poolConfigState, + tokenBaseProgram, + tokenQuoteProgram, + }) - return this.program.methods - .claimTradingFee(maxBaseAmount, maxQuoteAmount) - .accountsPartial({ - ...result.accounts, - config: virtualPool.poolState.config, - feeClaimer, - }) - .preInstructions(result.preInstructions) - .postInstructions(result.postInstructions) - .transaction() - } else { - const feeReceiver = receiver ? receiver : feeClaimer - - const result = await this.buildClaimTradingFeeAccountsForNonSol({ - payer, - feeReceiver, - pool, - virtualPool, - poolConfigState, - tokenBaseProgram, - tokenQuoteProgram, + return this.program.methods + .claimTradingFee(maxBaseAmount, maxQuoteAmount) + .accountsPartial({ + ...result.accounts, + config: virtualPool.poolState.config, + feeClaimer, }) - - return this.program.methods - .claimTradingFee(maxBaseAmount, maxQuoteAmount) - .accountsPartial({ - ...result.accounts, - config: virtualPool.poolState.config, - feeClaimer, - }) - .preInstructions(result.preInstructions) - .transaction() - } + .preInstructions(result.preInstructions) + .postInstructions(result.postInstructions) + .transaction() } /** @@ -464,59 +478,40 @@ export class PartnerService extends DynamicBondingCurveProgram { receiver, } = params - const { virtualPool, poolConfigState } = + const { virtualPool, poolConfigState, isTransferHookPool } = await this.getPoolWithConfig(pool) + if (isTransferHookPool) { + throw new Error( + 'Pool uses a transfer hook, use claimPartnerTradingFee2' + ) + } const tokenBaseProgram = getTokenProgram(poolConfigState.tokenType) const tokenQuoteProgram = getTokenProgram( poolConfigState.quoteTokenFlag ) - const isSOLQuoteMint = isNativeSol(poolConfigState.quoteMint) - - if (isSOLQuoteMint) { - const result = await this.buildClaimTradingFeeAccountsForSol({ - payer, - feeReceiver: receiver, - tempWSolAcc: feeClaimer, - pool, - virtualPool, - poolConfigState, - tokenBaseProgram, - tokenQuoteProgram, - }) + const result = await this.resolveTradingFeeAccounts({ + payer, + feeReceiver: receiver, + tempWSolAcc: feeClaimer, + pool, + virtualPool, + poolConfigState, + tokenBaseProgram, + tokenQuoteProgram, + }) - return this.program.methods - .claimTradingFee(maxBaseAmount, maxQuoteAmount) - .accountsPartial({ - ...result.accounts, - config: virtualPool.poolState.config, - feeClaimer, - }) - .preInstructions(result.preInstructions) - .postInstructions(result.postInstructions) - .transaction() - } else { - const result = await this.buildClaimTradingFeeAccountsForNonSol({ - payer, - feeReceiver: receiver, - pool, - virtualPool, - poolConfigState, - tokenBaseProgram, - tokenQuoteProgram, + return this.program.methods + .claimTradingFee(maxBaseAmount, maxQuoteAmount) + .accountsPartial({ + ...result.accounts, + config: virtualPool.poolState.config, + feeClaimer, }) - return this.program.methods - .claimTradingFee(maxBaseAmount, maxQuoteAmount) - .accountsPartial({ - ...result.accounts, - config: virtualPool.poolState.config, - feeClaimer, - }) - .preInstructions(result.preInstructions) - .postInstructions([]) - .transaction() - } + .preInstructions(result.preInstructions) + .postInstructions(result.postInstructions) + .transaction() } /** @@ -542,38 +537,32 @@ export class PartnerService extends DynamicBondingCurveProgram { poolConfigState.quoteTokenFlag ) - const isSOLQuoteMint = isNativeSol(poolConfigState.quoteMint) - const result = isSOLQuoteMint - ? await this.buildClaimTradingFeeAccountsForSol({ - payer, - feeReceiver: receiver, - tempWSolAcc: feeClaimer, - pool, - virtualPool, - poolConfigState, - tokenBaseProgram, - tokenQuoteProgram, - }) - : await this.buildClaimTradingFeeAccountsForNonSol({ - payer, - feeReceiver: receiver, - pool, - virtualPool, - poolConfigState, - tokenBaseProgram, - tokenQuoteProgram, - }) + const result = await this.resolveTradingFeeAccounts({ + payer, + feeReceiver: receiver, + tempWSolAcc: feeClaimer, + pool, + virtualPool, + poolConfigState, + tokenBaseProgram, + tokenQuoteProgram, + }) const { info: transferHookAccountsInfo, accounts: transferHookAccounts, } = await this.getRemainingAccountsForTransferHook( - virtualPool.poolState.baseMint + virtualPool.poolState.baseMint, + [ + { + accountsType: AccountsType.TransferHookBase, + source: virtualPool.poolState.baseVault, + destination: result.accounts.tokenAAccount, + authority: this.poolAuthority, + }, + ] ) - const postInstructions: TransactionInstruction[] = - isSOLQuoteMint && 'postInstructions' in result - ? (result.postInstructions as TransactionInstruction[]) - : [] + const postInstructions = result.postInstructions return this.program.methods .claimTradingFee2( diff --git a/packages/dynamic-bonding-curve/src/services/pool.ts b/packages/dynamic-bonding-curve/src/services/pool.ts index 2452badc..8fb2fb02 100644 --- a/packages/dynamic-bonding-curve/src/services/pool.ts +++ b/packages/dynamic-bonding-curve/src/services/pool.ts @@ -1,10 +1,8 @@ +import { Transaction } from '@solana/web3.js' import { - TransactionInstruction, - Transaction, - SYSVAR_INSTRUCTIONS_PUBKEY, - AccountMeta, -} from '@solana/web3.js' -import { DynamicBondingCurveProgram } from './program' + DynamicBondingCurveProgram, + type TransferHookTransfer, +} from './program' import { AccountsType, SwapMode, @@ -15,27 +13,20 @@ import { SwapQuoteResult, SwapQuote2Result, TradeDirection, - BaseFeeMode, - type PoolConfig, - type VirtualPool, - type SwapQuoteConfig, type SimulatedQuoteFromInputAmountParams, type SimulatedQuoteFromOutputAmountParams, } from '../types' +import { validateSwapAmount } from '../helpers' import { - unwrapSOLInstruction, - wrapSOLInstruction, - validateSwapAmount, - getCurrentPoint, - getMigrationThresholdPrice, -} from '../helpers' -import { NATIVE_MINT } from '@solana/spl-token' + getQuoteFromInputAmount as quoteFromInputAmount, + getQuoteFromOutputAmount as quoteFromOutputAmount, +} from '../helpers/quoteSwap' import { + swapQuote, swapQuoteExactIn, swapQuoteExactOut, swapQuotePartialFill, - swapQuote, - isRateLimiterApplied, + getFeeMode, } from '../math' import BN from 'bn.js' @@ -57,38 +48,26 @@ export class PoolService extends DynamicBondingCurveProgram { referralTokenAccount, } = params - const { virtualPool, poolConfigState } = + const { virtualPool, poolConfigState, isTransferHookPool } = await this.getPoolWithConfig(pool) + if (isTransferHookPool) { + throw new Error( + 'Pool uses a transfer hook, use swap2WithTransferHook' + ) + } - // error checks validateSwapAmount(amountIn) - // check if rate limiter is applied if: - // 1. rate limiter mode - // 2. swap direction is QuoteToBase - // 3. current point is greater than activation point - // 4. current point is less than activation point + maxLimiterDuration - let rateLimiterApplied = false - if ( - poolConfigState.poolFees.baseFee.baseFeeMode === - BaseFeeMode.RateLimiter - ) { - const currentPoint = await getCurrentPoint( - this.connection, - poolConfigState.activationType - ) - - rateLimiterApplied = isRateLimiterApplied( - currentPoint, - virtualPool.poolState.activationPoint, - swapBaseForQuote - ? TradeDirection.BaseToQuote - : TradeDirection.QuoteToBase, - poolConfigState.poolFees.baseFee.secondFactor, - poolConfigState.poolFees.baseFee.thirdFactor, - new BN(poolConfigState.poolFees.baseFee.firstFactor) - ) - } + const rateLimited = await this.rateLimiterApplied({ + connection: this.connection, + baseFeeMode: poolConfigState.poolFees.baseFee.baseFeeMode, + firstFactor: poolConfigState.poolFees.baseFee.firstFactor, + secondFactor: poolConfigState.poolFees.baseFee.secondFactor, + thirdFactor: poolConfigState.poolFees.baseFee.thirdFactor, + activationType: poolConfigState.activationType, + activationPoint: virtualPool.poolState.activationPoint, + swapBaseForQuote, + }) const { inputMint, outputMint, inputTokenProgram, outputTokenProgram } = this.prepareSwapParams( @@ -97,52 +76,28 @@ export class PoolService extends DynamicBondingCurveProgram { poolConfigState ) - // add preInstructions for ATA creation and SOL wrapping const { - ataTokenA: inputTokenAccount, - ataTokenB: outputTokenAccount, - instructions: preInstructions, - } = await this.prepareTokenAccounts( - owner, - payer ? payer : owner, + inputTokenAccount, + outputTokenAccount, + preInstructions, + postInstructions, + remainingAccounts, + } = await this.prepareSwapAccounts({ + connection: this.connection, + commitment: this.commitment, + payer: payer ? payer : owner, + inputOwner: owner, + outputOwner: owner, + unwrapAuthority: owner, inputMint, outputMint, inputTokenProgram, - outputTokenProgram - ) - - // add SOL wrapping instructions if needed - if (inputMint.equals(NATIVE_MINT)) { - preInstructions.push( - ...wrapSOLInstruction( - owner, - inputTokenAccount, - BigInt(amountIn.toString()) - ) - ) - } - - // add postInstructions for SOL unwrapping - const postInstructions: TransactionInstruction[] = [] - if ( - [inputMint.toBase58(), outputMint.toBase58()].includes( - NATIVE_MINT.toBase58() - ) - ) { - const unwrapIx = unwrapSOLInstruction(owner, owner) - unwrapIx && postInstructions.push(unwrapIx) - } - - const remainingAccounts: AccountMeta[] = [] - - // add remaining accounts if rate limiter is applied or enableFirstSwapWithMinFee is true - if (rateLimiterApplied || poolConfigState.enableFirstSwapWithMinFee) { - remainingAccounts.push({ - pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, - isSigner: false, - isWritable: false, - }) - } + outputTokenProgram, + wrapAmount: amountIn, + includeInstructionSysvar: + rateLimited || + Boolean(poolConfigState.enableFirstSwapWithMinFee), + }) return this.program.methods .swap({ @@ -201,39 +156,27 @@ export class PoolService extends DynamicBondingCurveProgram { amount1 = params.minimumAmountOut } - // error checks validateSwapAmount(amount0) - const { virtualPool, poolConfigState } = + const { virtualPool, poolConfigState, isTransferHookPool } = await this.getPoolWithConfig(pool) - - // check if rate limiter is applied if: - // 1. rate limiter mode - // 2. swap direction is QuoteToBase - // 3. current point is greater than activation point - // 4. current point is less than activation point + maxLimiterDuration - let rateLimiterApplied = false - if ( - poolConfigState.poolFees.baseFee.baseFeeMode === - BaseFeeMode.RateLimiter - ) { - const currentPoint = await getCurrentPoint( - this.connection, - poolConfigState.activationType - ) - - rateLimiterApplied = isRateLimiterApplied( - currentPoint, - virtualPool.poolState.activationPoint, - swapBaseForQuote - ? TradeDirection.BaseToQuote - : TradeDirection.QuoteToBase, - poolConfigState.poolFees.baseFee.secondFactor, - poolConfigState.poolFees.baseFee.thirdFactor, - new BN(poolConfigState.poolFees.baseFee.firstFactor) + if (isTransferHookPool) { + throw new Error( + 'Pool uses a transfer hook, use swap2WithTransferHook' ) } + const rateLimited = await this.rateLimiterApplied({ + connection: this.connection, + baseFeeMode: poolConfigState.poolFees.baseFee.baseFeeMode, + firstFactor: poolConfigState.poolFees.baseFee.firstFactor, + secondFactor: poolConfigState.poolFees.baseFee.secondFactor, + thirdFactor: poolConfigState.poolFees.baseFee.thirdFactor, + activationType: poolConfigState.activationType, + activationPoint: virtualPool.poolState.activationPoint, + swapBaseForQuote, + }) + const { inputMint, outputMint, inputTokenProgram, outputTokenProgram } = this.prepareSwapParams( swapBaseForQuote, @@ -241,57 +184,32 @@ export class PoolService extends DynamicBondingCurveProgram { poolConfigState ) - // add preInstructions for ATA creation and SOL wrapping + const wrapAmount = + swapMode === SwapMode.ExactIn || swapMode === SwapMode.PartialFill + ? amount0 + : amount1 const { - ataTokenA: inputTokenAccount, - ataTokenB: outputTokenAccount, - instructions: preInstructions, - } = await this.prepareTokenAccounts( - owner, - payer ? payer : owner, + inputTokenAccount, + outputTokenAccount, + preInstructions, + postInstructions, + remainingAccounts, + } = await this.prepareSwapAccounts({ + connection: this.connection, + commitment: this.commitment, + payer: payer ? payer : owner, + inputOwner: owner, + outputOwner: owner, + unwrapAuthority: owner, inputMint, outputMint, inputTokenProgram, - outputTokenProgram - ) - - // add SOL wrapping instructions if needed - if (inputMint.equals(NATIVE_MINT)) { - const amount = - swapMode === SwapMode.ExactIn || - swapMode === SwapMode.PartialFill - ? amount0 - : amount1 - preInstructions.push( - ...wrapSOLInstruction( - owner, - inputTokenAccount, - BigInt(amount.toString()) - ) - ) - } - - // add postInstructions for SOL unwrapping - const postInstructions: TransactionInstruction[] = [] - if ( - [inputMint.toBase58(), outputMint.toBase58()].includes( - NATIVE_MINT.toBase58() - ) - ) { - const unwrapIx = unwrapSOLInstruction(owner, owner) - unwrapIx && postInstructions.push(unwrapIx) - } - - const remainingAccounts: AccountMeta[] = [] - - // add remaining accounts if rate limiter is applied - if (rateLimiterApplied || poolConfigState.enableFirstSwapWithMinFee) { - remainingAccounts.push({ - pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, - isSigner: false, - isWritable: false, - }) - } + outputTokenProgram, + wrapAmount, + includeInstructionSysvar: + rateLimited || + Boolean(poolConfigState.enableFirstSwapWithMinFee), + }) return this.program.methods .swap2({ @@ -350,31 +268,23 @@ export class PoolService extends DynamicBondingCurveProgram { validateSwapAmount(amount0) - const { virtualPool, poolConfigState } = + const { virtualPool, poolConfigState, isTransferHookPool } = await this.getPoolWithConfig(pool) - - let rateLimiterApplied = false - if ( - poolConfigState.poolFees.baseFee.baseFeeMode === - BaseFeeMode.RateLimiter - ) { - const currentPoint = await getCurrentPoint( - this.connection, - poolConfigState.activationType - ) - - rateLimiterApplied = isRateLimiterApplied( - currentPoint, - virtualPool.poolState.activationPoint, - swapBaseForQuote - ? TradeDirection.BaseToQuote - : TradeDirection.QuoteToBase, - poolConfigState.poolFees.baseFee.secondFactor, - poolConfigState.poolFees.baseFee.thirdFactor, - new BN(poolConfigState.poolFees.baseFee.firstFactor) - ) + if (!isTransferHookPool) { + throw new Error('Pool does not use a transfer hook, use swap2') } + const rateLimited = await this.rateLimiterApplied({ + connection: this.connection, + baseFeeMode: poolConfigState.poolFees.baseFee.baseFeeMode, + firstFactor: poolConfigState.poolFees.baseFee.firstFactor, + secondFactor: poolConfigState.poolFees.baseFee.secondFactor, + thirdFactor: poolConfigState.poolFees.baseFee.thirdFactor, + activationType: poolConfigState.activationType, + activationPoint: virtualPool.poolState.activationPoint, + swapBaseForQuote, + }) + const { inputMint, outputMint, inputTokenProgram, outputTokenProgram } = this.prepareSwapParams( swapBaseForQuote, @@ -382,66 +292,72 @@ export class PoolService extends DynamicBondingCurveProgram { poolConfigState ) + const wrapAmount = + swapMode === SwapMode.ExactIn || swapMode === SwapMode.PartialFill + ? amount0 + : amount1 const { - ataTokenA: inputTokenAccount, - ataTokenB: outputTokenAccount, - instructions: preInstructions, - } = await this.prepareTokenAccounts( - owner, - payer ? payer : owner, + inputTokenAccount, + outputTokenAccount, + preInstructions, + postInstructions, + remainingAccounts, + } = await this.prepareSwapAccounts({ + connection: this.connection, + commitment: this.commitment, + payer: payer ? payer : owner, + inputOwner: owner, + outputOwner: owner, + unwrapAuthority: owner, inputMint, outputMint, inputTokenProgram, - outputTokenProgram - ) - - if (inputMint.equals(NATIVE_MINT)) { - const amount = - swapMode === SwapMode.ExactIn || - swapMode === SwapMode.PartialFill - ? amount0 - : amount1 - preInstructions.push( - ...wrapSOLInstruction( - owner, - inputTokenAccount, - BigInt(amount.toString()) - ) - ) - } - - const postInstructions: TransactionInstruction[] = [] + outputTokenProgram, + wrapAmount, + includeInstructionSysvar: + rateLimited || + Boolean(poolConfigState.enableFirstSwapWithMinFee), + }) + + const baseVault = virtualPool.poolState.baseVault + const transferHookTransfers: TransferHookTransfer[] = [ + swapBaseForQuote + ? { + accountsType: AccountsType.TransferHookBase, + source: inputTokenAccount, + destination: baseVault, + authority: owner, + } + : { + accountsType: AccountsType.TransferHookBase, + source: baseVault, + destination: outputTokenAccount, + authority: this.poolAuthority, + }, + ] if ( - [inputMint.toBase58(), outputMint.toBase58()].includes( - NATIVE_MINT.toBase58() - ) + referralTokenAccount != null && + getFeeMode( + poolConfigState.collectFeeMode, + swapBaseForQuote + ? TradeDirection.BaseToQuote + : TradeDirection.QuoteToBase, + true + ).feesOnBaseToken ) { - const unwrapIx = unwrapSOLInstruction(owner, owner) - unwrapIx && postInstructions.push(unwrapIx) - } - - const remainingAccounts: AccountMeta[] = [] - if (rateLimiterApplied || poolConfigState.enableFirstSwapWithMinFee) { - remainingAccounts.push({ - pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, - isSigner: false, - isWritable: false, + transferHookTransfers.push({ + accountsType: AccountsType.TransferHookBaseReferral, + source: baseVault, + destination: referralTokenAccount, + authority: this.poolAuthority, }) } - - const transferHookAccountTypes = - referralTokenAccount != null - ? [ - AccountsType.TransferHookBase, - AccountsType.TransferHookBaseReferral, - ] - : [AccountsType.TransferHookBase] const { info: transferHookAccountsInfo, accounts: transferHookAccounts, } = await this.getRemainingAccountsForTransferHook( virtualPool.poolState.baseMint, - transferHookAccountTypes + transferHookTransfers ) remainingAccounts.push(...transferHookAccounts) @@ -503,217 +419,84 @@ export class PoolService extends DynamicBondingCurveProgram { slippageBps, hasReferral, currentPoint, - eligibleForFirstSwapWithMinFee + eligibleForFirstSwapWithMinFee, + params ) } /** - * Quote a swap using `SwapMode.ExactIn`, `SwapMode.PartialFill`, or `SwapMode.ExactOut`. + * Quote an existing pool with `SwapMode.ExactIn`, `SwapMode.PartialFill`, or `SwapMode.ExactOut`. + * `currentPoint` is the chain point. It is not taken from `activationPoint`. */ swapQuote2(params: SwapQuote2Params): SwapQuote2Result { const { virtualPool, config, swapBaseForQuote, - swapMode, hasReferral, eligibleForFirstSwapWithMinFee, currentPoint, slippageBps, } = params + const slippage = slippageBps ?? 0 - switch (swapMode) { - case SwapMode.ExactIn: - if ('amountIn' in params) { - return swapQuoteExactIn( - virtualPool, - config, - swapBaseForQuote, - params.amountIn, - slippageBps, - hasReferral, - currentPoint, - eligibleForFirstSwapWithMinFee - ) - } - throw new Error('amountIn is required for ExactIn swap mode') - - case SwapMode.ExactOut: - if ('amountOut' in params) { - return swapQuoteExactOut( - virtualPool, - config, - swapBaseForQuote, - params.amountOut, - slippageBps, - hasReferral, - currentPoint, - eligibleForFirstSwapWithMinFee - ) - } - throw new Error('outAmount is required for ExactOut swap mode') - - case SwapMode.PartialFill: - if ('amountIn' in params) { - return swapQuotePartialFill( - virtualPool, - config, - swapBaseForQuote, - params.amountIn, - slippageBps, - hasReferral, - currentPoint, - eligibleForFirstSwapWithMinFee - ) - } - throw new Error( - 'amountIn is required for PartialFill swap mode' - ) - - default: - throw new Error(`Unsupported swap mode: ${swapMode}`) - } - } - - /** - * Reconcile the only two fields that differ between an on-chain `PoolConfig` - * and a `buildCurve` output (`ConfigParameters`) so the quote math can - * consume either directly: - * - `migrationSqrtPrice`: used when present, otherwise derived from the - * curve and migration quote threshold (it is the swap stop price). - * - `dynamicFee`: a null/undefined object becomes a disabled one, and a - * present object is treated as enabled unless `initialized` says otherwise. - * - * Everything else is already in the right shape and passed through. - */ - private normalizeQuoteConfig(config: SwapQuoteConfig): PoolConfig { - if (!config.curve || config.curve.length === 0) { - throw new Error('config.curve is empty') - } - - const migrationSqrtPrice = - config.migrationSqrtPrice ?? - getMigrationThresholdPrice( - config.migrationQuoteThreshold, - config.sqrtStartPrice, - config.curve + if (params.swapMode === SwapMode.ExactOut) { + return swapQuoteExactOut( + virtualPool, + config, + swapBaseForQuote, + params.amountOut, + slippage, + hasReferral, + currentPoint, + eligibleForFirstSwapWithMinFee, + params ) + } - const dynamicFee = config.poolFees.dynamicFee - - return { - ...config, - migrationSqrtPrice, - poolFees: { - ...config.poolFees, - dynamicFee: dynamicFee - ? { - ...dynamicFee, - initialized: dynamicFee.initialized ?? 1, - } - : { initialized: 0, binStep: 0, variableFeeControl: 0 }, - }, - } as unknown as PoolConfig - } - - /** - * creates a virtual pool state at launch with zeroed reserves and volatility, start price set. - */ - private buildSimulatedVirtualPool(sqrtStartPrice: BN): VirtualPool { - return { - poolState: { - sqrtPrice: new BN(sqrtStartPrice), - baseReserve: new BN(0), - quoteReserve: new BN(0), - activationPoint: new BN(0), - volatilityTracker: { - lastUpdateTimestamp: new BN(0), - sqrtPriceReference: new BN(0), - volatilityAccumulator: new BN(0), - volatilityReference: new BN(0), - padding: [], - }, - }, - } as unknown as VirtualPool - } - - /** - * quotes a swap from an input amount before any pool exists. - */ - getQuoteFromInputAmount( - params: SimulatedQuoteFromInputAmountParams - ): SwapQuote2Result { - const { - config, - swapBaseForQuote, - amountIn, - swapMode = SwapMode.ExactIn, - slippageBps = 0, - hasReferral = false, - eligibleForFirstSwapWithMinFee = false, - currentPoint = new BN(0), - } = params - - const poolConfig = this.normalizeQuoteConfig(config) - const virtualPool = this.buildSimulatedVirtualPool( - poolConfig.sqrtStartPrice - ) - - if (swapMode === SwapMode.PartialFill) { + if (params.swapMode === SwapMode.PartialFill) { return swapQuotePartialFill( virtualPool, - poolConfig, + config, swapBaseForQuote, - amountIn, - slippageBps, + params.amountIn, + slippage, hasReferral, currentPoint, - eligibleForFirstSwapWithMinFee + eligibleForFirstSwapWithMinFee, + params ) } return swapQuoteExactIn( virtualPool, - poolConfig, + config, swapBaseForQuote, - amountIn, - slippageBps, + params.amountIn, + slippage, hasReferral, currentPoint, - eligibleForFirstSwapWithMinFee + eligibleForFirstSwapWithMinFee, + params ) } /** - * quotes a swap from an exact output amount (`SwapMode.ExactOut`) + * Quote a `ConfigParameters` curve before a pool exists. + * The quote is the pool `initialize_pool` would write at `currentPoint`. + */ + getQuoteFromInputAmount( + params: SimulatedQuoteFromInputAmountParams + ): SwapQuote2Result { + return quoteFromInputAmount(params) + } + + /** + * Quote an exact-out swap on a `ConfigParameters` curve before a pool exists. */ getQuoteFromOutputAmount( params: SimulatedQuoteFromOutputAmountParams ): SwapQuote2Result { - const { - config, - swapBaseForQuote, - amountOut, - slippageBps = 0, - hasReferral = false, - eligibleForFirstSwapWithMinFee = false, - currentPoint = new BN(0), - } = params - - const poolConfig = this.normalizeQuoteConfig(config) - const virtualPool = this.buildSimulatedVirtualPool( - poolConfig.sqrtStartPrice - ) - - return swapQuoteExactOut( - virtualPool, - poolConfig, - swapBaseForQuote, - amountOut, - slippageBps, - hasReferral, - currentPoint, - eligibleForFirstSwapWithMinFee - ) + return quoteFromOutputAmount(params) } } diff --git a/packages/dynamic-bonding-curve/src/services/program.ts b/packages/dynamic-bonding-curve/src/services/program.ts index f2442f38..6a18cd2e 100644 --- a/packages/dynamic-bonding-curve/src/services/program.ts +++ b/packages/dynamic-bonding-curve/src/services/program.ts @@ -9,20 +9,24 @@ import { } from '@solana/web3.js' import { deriveDbcPoolAddress, - createDbcProgram, deriveDbcPoolAuthority, deriveDbcTokenVaultAddress, deriveMintMetadata, - getCurrentPoint, getOrCreateATAInstruction, getTokenProgram, getTokenType, getTokenBadgeRemainingAccounts, + deriveTokenBadgeAddress, + hasTransferFeeOrConfigAuthority, + isSupportedQuoteMint, unwrapSOLInstruction, + wrapSOLInstruction, + getCurrentPoint, + isNativeSol, validateConfigParameters, validateSwapAmount, validateTransferHookProgram, - wrapSOLInstruction, + validateTransferHookProgramExecutable, findAssociatedTokenAddress, } from '../helpers' import type { Program } from '@coral-xyz/anchor' @@ -32,6 +36,7 @@ import { AccountsType, BaseFee, BaseFeeMode, + CollectFeeMode, ConfigParameters, CreatePoolParams, CreatePoolWithTransferHookParams, @@ -43,6 +48,7 @@ import { SwapMode, TokenType, TradeDirection, + TransferFeeParameters, TransferHookAccountsInfo, VirtualPool, } from '../types' @@ -55,9 +61,11 @@ import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, unpackMint, + type Mint, } from '@solana/spl-token' -import { isRateLimiterApplied } from '../math' +import { getEpochTransferFee, getFeeMode, isRateLimiterApplied } from '../math' import BN from 'bn.js' +import { createDbcProgram } from '../helpers/createProgram' import { StateService } from './state' type ClaimTradingFeeAccountParams = { @@ -76,6 +84,13 @@ type ClaimTradingFeeSolAccountParams = ClaimTradingFeeAccountParams & { type AccountsTypeValue = (typeof AccountsType)[keyof typeof AccountsType] +export type TransferHookTransfer = { + accountsType: AccountsTypeValue + source: PublicKey + destination: PublicKey + authority: PublicKey +} + export class DynamicBondingCurveProgram { program: Program protected connection: Connection @@ -95,8 +110,12 @@ export class DynamicBondingCurveProgram { protected async getPoolWithConfig(pool: PublicKey | string): Promise<{ virtualPool: VirtualPool poolConfigState: PoolConfig + isTransferHookPool: boolean }> { - const virtualPool = await this.state.getPool(pool) + const [virtualPool, isTransferHookPool] = await Promise.all([ + this.state.getPool(pool), + this.state.isTransferHookPool(pool), + ]) if (!virtualPool) { throw new Error(`Pool not found: ${pool.toString()}`) } @@ -108,7 +127,7 @@ export class DynamicBondingCurveProgram { throw new Error(`Pool config not found for virtual pool`) } - return { virtualPool, poolConfigState } + return { virtualPool, poolConfigState, isTransferHookPool } } protected prepareSwapParams( @@ -141,6 +160,55 @@ export class DynamicBondingCurveProgram { } } + protected async getQuoteMintState(quoteMint: PublicKey): Promise<{ + mint: Mint + tokenProgram: PublicKey + currentEpoch: number + }> { + const [accountInfo, { epoch }] = await Promise.all([ + this.connection.getAccountInfo(quoteMint, this.commitment), + this.connection.getEpochInfo(this.commitment), + ]) + if (!accountInfo) { + throw new Error(`Quote mint not found: ${quoteMint.toBase58()}`) + } + return { + mint: unpackMint(quoteMint, accountInfo, accountInfo.owner), + tokenProgram: accountInfo.owner, + currentEpoch: epoch, + } + } + + protected async resolveQuoteMintTokenBadge( + quoteMint: Mint, + currentEpoch: number, + tokenBadge?: PublicKey + ): Promise { + if (isSupportedQuoteMint(quoteMint, currentEpoch)) { + return tokenBadge + } + + const expectedTokenBadge = deriveTokenBadgeAddress(quoteMint.address) + if (tokenBadge && !tokenBadge.equals(expectedTokenBadge)) { + throw new Error( + `Invalid token badge for quote mint ${quoteMint.address.toBase58()}` + ) + } + const tokenBadgeAccount = await this.connection.getAccountInfo( + expectedTokenBadge, + this.commitment + ) + if ( + !tokenBadgeAccount || + !tokenBadgeAccount.owner.equals(this.program.programId) + ) { + throw new Error( + `Quote mint ${quoteMint.address.toBase58()} requires an initialized token badge` + ) + } + return expectedTokenBadge + } + protected async buildCreateConfigTx( configParam: ConfigParameters, config: PublicKey, @@ -151,6 +219,17 @@ export class DynamicBondingCurveProgram { tokenBadge?: PublicKey ): Promise { validateConfigParameters({ ...configParam, leftoverReceiver }) + const { mint, currentEpoch } = await this.getQuoteMintState(quoteMint) + if (hasTransferFeeOrConfigAuthority(mint, currentEpoch)) { + throw new Error( + 'Quote mint has a non-zero transfer fee or a live transfer fee config authority, use createConfig2' + ) + } + const resolvedTokenBadge = await this.resolveQuoteMintTokenBadge( + mint, + currentEpoch, + tokenBadge + ) return this.program.methods .createConfig(configParam) @@ -161,7 +240,52 @@ export class DynamicBondingCurveProgram { quoteMint, payer, }) - .remainingAccounts(getTokenBadgeRemainingAccounts(tokenBadge)) + .remainingAccounts( + getTokenBadgeRemainingAccounts(resolvedTokenBadge) + ) + .transaction() + } + + protected async buildCreateConfig2Tx( + configParam: ConfigParameters, + config: PublicKey, + feeClaimer: PublicKey, + leftoverReceiver: PublicKey, + quoteMint: PublicKey, + payer: PublicKey, + transferFeeParameters: TransferFeeParameters | null, + tokenBadge?: PublicKey + ): Promise { + const { mint, currentEpoch } = await this.getQuoteMintState(quoteMint) + validateConfigParameters( + { ...configParam, leftoverReceiver }, + { + transferFeeParameters, + quoteMintHasTransferFee: hasTransferFeeOrConfigAuthority( + mint, + currentEpoch + ), + quoteEpochTransferFee: getEpochTransferFee(mint, currentEpoch), + } + ) + const resolvedTokenBadge = await this.resolveQuoteMintTokenBadge( + mint, + currentEpoch, + tokenBadge + ) + + return this.program.methods + .createConfig2(configParam, transferFeeParameters) + .accountsPartial({ + config, + feeClaimer, + leftoverReceiver, + quoteMint, + payer, + }) + .remainingAccounts( + getTokenBadgeRemainingAccounts(resolvedTokenBadge) + ) .transaction() } @@ -179,6 +303,27 @@ export class DynamicBondingCurveProgram { { ...configParam, leftoverReceiver }, { isTransferHook: true, transferHookProgram } ) + const { mint, currentEpoch } = await this.getQuoteMintState(quoteMint) + if (hasTransferFeeOrConfigAuthority(mint, currentEpoch)) { + throw new Error( + 'Quote mint has a non-zero transfer fee or a live transfer fee config authority' + ) + } + if ( + !(await validateTransferHookProgramExecutable( + this.connection, + transferHookProgram + )) + ) { + throw new Error( + `Transfer hook program ${transferHookProgram.toBase58()} is not an executable account` + ) + } + const resolvedTokenBadge = await this.resolveQuoteMintTokenBadge( + mint, + currentEpoch, + tokenBadge + ) return this.program.methods .createConfigWithTransferHook(configParam) @@ -190,7 +335,9 @@ export class DynamicBondingCurveProgram { transferHookProgram, payer, }) - .remainingAccounts(getTokenBadgeRemainingAccounts(tokenBadge)) + .remainingAccounts( + getTokenBadgeRemainingAccounts(resolvedTokenBadge) + ) .transaction() } @@ -348,11 +495,11 @@ export class DynamicBondingCurveProgram { const baseVault = deriveDbcTokenVaultAddress(pool, baseMint) const quoteVault = deriveDbcTokenVaultAddress(pool, quoteMint) - const quoteTokenType = await getTokenType(this.connection, quoteMint) - if (quoteTokenType === null) { - throw new Error(`Invalid quote mint: ${quoteMint.toString()}`) - } - const tokenQuoteProgram = getTokenProgram(quoteTokenType) + const { + mint, + tokenProgram: tokenQuoteProgram, + currentEpoch, + } = await this.getQuoteMintState(quoteMint) const baseParams: InitializePoolBaseParams = { name, @@ -366,7 +513,11 @@ export class DynamicBondingCurveProgram { baseVault, quoteVault, quoteMint, - tokenBadge, + tokenBadge: await this.resolveQuoteMintTokenBadge( + mint, + currentEpoch, + tokenBadge + ), } if (tokenType === TokenType.SPLToken) { @@ -410,6 +561,7 @@ export class DynamicBondingCurveProgram { const pool = deriveDbcPoolAddress(quoteMint, baseMint, config) const baseVault = deriveDbcTokenVaultAddress(pool, baseMint) const quoteVault = deriveDbcTokenVaultAddress(pool, quoteMint) + const { mint, currentEpoch } = await this.getQuoteMintState(quoteMint) return this.initializeToken2022PoolWithTransferHook({ name, @@ -425,7 +577,11 @@ export class DynamicBondingCurveProgram { quoteMint, transferHookProgram, tokenQuoteProgram, - tokenBadge, + tokenBadge: await this.resolveQuoteMintTokenBadge( + mint, + currentEpoch, + tokenBadge + ), }) } @@ -450,24 +606,16 @@ export class DynamicBondingCurveProgram { validateSwapAmount(buyAmount) - let rateLimiterApplied = false - if (baseFee.baseFeeMode === BaseFeeMode.RateLimiter) { - const currentPoint = await getCurrentPoint( - this.connection, - activationType - ) - - rateLimiterApplied = isRateLimiterApplied( - currentPoint, - new BN(0), - swapBaseForQuote - ? TradeDirection.BaseToQuote - : TradeDirection.QuoteToBase, - baseFee.secondFactor, - baseFee.thirdFactor, - new BN(baseFee.firstFactor) - ) - } + const rateLimited = await this.rateLimiterApplied({ + connection: this.connection, + baseFeeMode: baseFee.baseFeeMode, + firstFactor: baseFee.firstFactor, + secondFactor: baseFee.secondFactor, + thirdFactor: baseFee.thirdFactor, + activationType, + activationPoint: new BN(0), + swapBaseForQuote, + }) const quoteTokenFlag = await getTokenType(this.connection, quoteMint) @@ -488,62 +636,26 @@ export class DynamicBondingCurveProgram { const baseVault = deriveDbcTokenVaultAddress(pool, baseMint) const quoteVault = deriveDbcTokenVaultAddress(pool, quoteMint) - const preInstructions: TransactionInstruction[] = [] - - const [ - { ataPubkey: inputTokenAccount, ix: createAtaTokenAIx }, - { ataPubkey: outputTokenAccount, ix: createAtaTokenBIx }, - ] = await Promise.all([ - getOrCreateATAInstruction( - this.connection, - inputMint, - buyer, - buyer, - true, - inputTokenProgram, - this.commitment - ), - getOrCreateATAInstruction( - this.connection, - outputMint, - receiver ? receiver : buyer, - buyer, - true, - outputTokenProgram, - this.commitment - ), - ]) - createAtaTokenAIx && preInstructions.push(createAtaTokenAIx) - createAtaTokenBIx && preInstructions.push(createAtaTokenBIx) - - if (inputMint.equals(NATIVE_MINT)) { - preInstructions.push( - ...wrapSOLInstruction( - buyer, - inputTokenAccount, - BigInt(buyAmount.toString()) - ) - ) - } - - const postInstructions: TransactionInstruction[] = [] - if ( - [inputMint.toBase58(), outputMint.toBase58()].includes( - NATIVE_MINT.toBase58() - ) - ) { - const unwrapIx = unwrapSOLInstruction(buyer, buyer) - unwrapIx && postInstructions.push(unwrapIx) - } - - const remainingAccounts: AccountMeta[] = [] - if (rateLimiterApplied || enableFirstSwapWithMinFee) { - remainingAccounts.push({ - isSigner: false, - isWritable: false, - pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, - }) - } + const { + inputTokenAccount, + outputTokenAccount, + preInstructions, + postInstructions, + remainingAccounts, + } = await this.prepareSwapAccounts({ + connection: this.connection, + commitment: this.commitment, + payer: buyer, + inputOwner: buyer, + outputOwner: receiver ? receiver : buyer, + unwrapAuthority: buyer, + inputMint, + outputMint, + inputTokenProgram, + outputTokenProgram, + wrapAmount: buyAmount, + includeInstructionSysvar: rateLimited || enableFirstSwapWithMinFee, + }) return this.program.methods .swap({ @@ -577,6 +689,7 @@ export class DynamicBondingCurveProgram { config: PublicKey, baseFee: BaseFee, activationType: ActivationType, + collectFeeMode: CollectFeeMode, quoteMint: PublicKey, enableFirstSwapWithMinFee: boolean ): Promise { @@ -590,22 +703,16 @@ export class DynamicBondingCurveProgram { validateSwapAmount(buyAmount) - let rateLimiterApplied = false - if (baseFee.baseFeeMode === BaseFeeMode.RateLimiter) { - const currentPoint = await getCurrentPoint( - this.connection, - activationType - ) - - rateLimiterApplied = isRateLimiterApplied( - currentPoint, - new BN(0), - TradeDirection.QuoteToBase, - baseFee.secondFactor, - baseFee.thirdFactor, - new BN(baseFee.firstFactor) - ) - } + const rateLimited = await this.rateLimiterApplied({ + connection: this.connection, + baseFeeMode: baseFee.baseFeeMode, + firstFactor: baseFee.firstFactor, + secondFactor: baseFee.secondFactor, + thirdFactor: baseFee.thirdFactor, + activationType, + activationPoint: new BN(0), + swapBaseForQuote: false, + }) const quoteTokenFlag = await getTokenType(this.connection, quoteMint) const { inputMint, outputMint, inputTokenProgram, outputTokenProgram } = @@ -624,70 +731,47 @@ export class DynamicBondingCurveProgram { const pool = deriveDbcPoolAddress(quoteMint, baseMint, config) const baseVault = deriveDbcTokenVaultAddress(pool, baseMint) const quoteVault = deriveDbcTokenVaultAddress(pool, quoteMint) - const preInstructions: TransactionInstruction[] = [] - - const [ - { ataPubkey: inputTokenAccount, ix: createAtaTokenAIx }, - { ataPubkey: outputTokenAccount, ix: createAtaTokenBIx }, - ] = await Promise.all([ - getOrCreateATAInstruction( - this.connection, - inputMint, - buyer, - buyer, - true, - inputTokenProgram, - this.commitment - ), - getOrCreateATAInstruction( - this.connection, - outputMint, - receiver ? receiver : buyer, - buyer, - true, - outputTokenProgram, - this.commitment - ), - ]) - createAtaTokenAIx && preInstructions.push(createAtaTokenAIx) - createAtaTokenBIx && preInstructions.push(createAtaTokenBIx) - - if (inputMint.equals(NATIVE_MINT)) { - preInstructions.push( - ...wrapSOLInstruction( - buyer, - inputTokenAccount, - BigInt(buyAmount.toString()) - ) - ) - } + const { + inputTokenAccount, + outputTokenAccount, + preInstructions, + postInstructions, + remainingAccounts, + } = await this.prepareSwapAccounts({ + connection: this.connection, + commitment: this.commitment, + payer: buyer, + inputOwner: buyer, + outputOwner: receiver ? receiver : buyer, + unwrapAuthority: buyer, + inputMint, + outputMint, + inputTokenProgram, + outputTokenProgram, + wrapAmount: buyAmount, + includeInstructionSysvar: rateLimited || enableFirstSwapWithMinFee, + }) - const postInstructions: TransactionInstruction[] = [] + const transferHookTransfers: TransferHookTransfer[] = [ + { + accountsType: AccountsType.TransferHookBase, + source: baseVault, + destination: outputTokenAccount, + authority: this.poolAuthority, + }, + ] if ( - [inputMint.toBase58(), outputMint.toBase58()].includes( - NATIVE_MINT.toBase58() - ) + referralTokenAccount != null && + getFeeMode(collectFeeMode, TradeDirection.QuoteToBase, true) + .feesOnBaseToken ) { - const unwrapIx = unwrapSOLInstruction(buyer, buyer) - unwrapIx && postInstructions.push(unwrapIx) - } - - const remainingAccounts: AccountMeta[] = [] - if (rateLimiterApplied || enableFirstSwapWithMinFee) { - remainingAccounts.push({ - isSigner: false, - isWritable: false, - pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, + transferHookTransfers.push({ + accountsType: AccountsType.TransferHookBaseReferral, + source: baseVault, + destination: referralTokenAccount, + authority: this.poolAuthority, }) } - - const transferHookAccountTypes = - referralTokenAccount != null - ? [ - AccountsType.TransferHookBase, - AccountsType.TransferHookBaseReferral, - ] - : [AccountsType.TransferHookBase] let transferHookAccountsResult: { info: TransferHookAccountsInfo accounts: AccountMeta[] @@ -705,7 +789,7 @@ export class DynamicBondingCurveProgram { transferHookAccountsResult = await this.getRemainingAccountsForTransferHook( baseMint, - transferHookAccountTypes + transferHookTransfers ) } catch { throw new Error( @@ -883,9 +967,35 @@ export class DynamicBondingCurveProgram { return { accounts, preInstructions } } + protected async resolveTradingFeeAccounts( + params: ClaimTradingFeeSolAccountParams + ): Promise<{ + accounts: { + poolAuthority: PublicKey + pool: PublicKey + tokenAAccount: PublicKey + tokenBAccount: PublicKey + baseVault: PublicKey + quoteVault: PublicKey + baseMint: PublicKey + quoteMint: PublicKey + tokenBaseProgram: PublicKey + tokenQuoteProgram: PublicKey + } + preInstructions: TransactionInstruction[] + postInstructions: TransactionInstruction[] + }> { + if (isNativeSol(params.poolConfigState.quoteMint)) { + return this.buildClaimTradingFeeAccountsForSol(params) + } + + const result = await this.buildClaimTradingFeeAccountsForNonSol(params) + return { ...result, postInstructions: [] } + } + protected async getRemainingAccountsForTransferHook( mint: PublicKey, - accountTypes: AccountsTypeValue[] = [AccountsType.TransferHookBase] + transfers: TransferHookTransfer[] ): Promise<{ info: TransferHookAccountsInfo accounts: AccountMeta[] @@ -913,26 +1023,29 @@ export class DynamicBondingCurveProgram { return emptyAccounts } - const transferWithHookIx = - await createTransferCheckedWithTransferHookInstruction( - this.connection, - PublicKey.default, - mint, - PublicKey.default, - PublicKey.default, - BigInt(0), - mintState.decimals, - [], - this.commitment, - TOKEN_2022_PROGRAM_ID - ) - - const transferHookAccounts = transferWithHookIx.keys.slice(4) - const slices = accountTypes.map((accountsType) => ({ - accountsType, - length: transferHookAccounts.length, - })) - const accounts = accountTypes.flatMap(() => transferHookAccounts) + const slices: TransferHookAccountsInfo['slices'] = [] + const accounts: AccountMeta[] = [] + for (const transfer of transfers) { + const transferWithHookIx = + await createTransferCheckedWithTransferHookInstruction( + this.connection, + transfer.source, + mint, + transfer.destination, + transfer.authority, + BigInt(0), + mintState.decimals, + [], + this.commitment, + TOKEN_2022_PROGRAM_ID + ) + const transferHookAccounts = transferWithHookIx.keys.slice(4) + slices.push({ + accountsType: transfer.accountsType, + length: transferHookAccounts.length, + }) + accounts.push(...transferHookAccounts) + } return { info: { slices }, accounts } } @@ -1028,6 +1141,132 @@ export class DynamicBondingCurveProgram { return { ataTokenA, ataTokenB, instructions } } + /** + * Return whether the rate limiter applies to this swap. + */ + protected async rateLimiterApplied(params: { + connection: Connection + baseFeeMode: BaseFeeMode + firstFactor: number + secondFactor: BN + thirdFactor: BN + activationType: ActivationType + activationPoint: BN + swapBaseForQuote: boolean + }): Promise { + if (params.baseFeeMode !== BaseFeeMode.RateLimiter) { + return false + } + + const currentPoint = await getCurrentPoint( + params.connection, + params.activationType + ) + + return isRateLimiterApplied( + currentPoint, + params.activationPoint, + params.swapBaseForQuote + ? TradeDirection.BaseToQuote + : TradeDirection.QuoteToBase, + params.secondFactor, + params.thirdFactor, + new BN(params.firstFactor) + ) + } + + /** + * Create the swap input and output token accounts, including SOL wrap, unwrap, and the instructions sysvar. + */ + protected async prepareSwapAccounts(params: { + connection: Connection + commitment: Commitment + payer: PublicKey + inputOwner: PublicKey + outputOwner: PublicKey + unwrapAuthority: PublicKey + inputMint: PublicKey + outputMint: PublicKey + inputTokenProgram: PublicKey + outputTokenProgram: PublicKey + wrapAmount: BN + includeInstructionSysvar: boolean + }): Promise<{ + inputTokenAccount: PublicKey + outputTokenAccount: PublicKey + preInstructions: TransactionInstruction[] + postInstructions: TransactionInstruction[] + remainingAccounts: AccountMeta[] + }> { + const preInstructions: TransactionInstruction[] = [] + const [ + { ataPubkey: inputTokenAccount, ix: createInputAtaIx }, + { ataPubkey: outputTokenAccount, ix: createOutputAtaIx }, + ] = await Promise.all([ + getOrCreateATAInstruction( + params.connection, + params.inputMint, + params.inputOwner, + params.payer, + true, + params.inputTokenProgram, + params.commitment + ), + getOrCreateATAInstruction( + params.connection, + params.outputMint, + params.outputOwner, + params.payer, + true, + params.outputTokenProgram, + params.commitment + ), + ]) + createInputAtaIx && preInstructions.push(createInputAtaIx) + createOutputAtaIx && preInstructions.push(createOutputAtaIx) + + if (params.inputMint.equals(NATIVE_MINT)) { + preInstructions.push( + ...wrapSOLInstruction( + params.inputOwner, + inputTokenAccount, + BigInt(params.wrapAmount.toString()) + ) + ) + } + + const postInstructions: TransactionInstruction[] = [] + if ( + [ + params.inputMint.toBase58(), + params.outputMint.toBase58(), + ].includes(NATIVE_MINT.toBase58()) + ) { + const unwrapIx = unwrapSOLInstruction( + params.unwrapAuthority, + params.unwrapAuthority + ) + unwrapIx && postInstructions.push(unwrapIx) + } + + const remainingAccounts: AccountMeta[] = [] + if (params.includeInstructionSysvar) { + remainingAccounts.push({ + pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, + isSigner: false, + isWritable: false, + }) + } + + return { + inputTokenAccount, + outputTokenAccount, + preInstructions, + postInstructions, + remainingAccounts, + } + } + /** * Return the underlying Anchor program client. */ diff --git a/packages/dynamic-bonding-curve/src/services/state.ts b/packages/dynamic-bonding-curve/src/services/state.ts index 75952290..50b236ab 100644 --- a/packages/dynamic-bonding-curve/src/services/state.ts +++ b/packages/dynamic-bonding-curve/src/services/state.ts @@ -13,6 +13,7 @@ import { } from '../helpers' import type { DynamicBondingCurve as DynamicBondingCurveIDL } from '../idl/dynamic-bonding-curve/idl' import { + ConfigWithTransferHook, MeteoraDammMigrationMetadata, PartnerMetadata, PoolConfig, @@ -23,14 +24,15 @@ import { import type { Program, ProgramAccount } from '@coral-xyz/anchor' import BN from 'bn.js' import Decimal from 'decimal.js' - export class StateService { program: Program + private connection: Connection private commitment: Commitment constructor(connection: Connection, commitment: Commitment) { const { program } = createDbcProgram(connection, commitment) this.program = program + this.connection = connection this.commitment = commitment } @@ -103,6 +105,58 @@ export class StateService { return configWithTransferHook?.config ?? null } + private async hasAccountDiscriminator( + address: PublicKey | string, + accountName: 'configWithTransferHook' | 'transferHookPool' + ): Promise { + const accountInfo = await this.connection.getAccountInfo( + new PublicKey(address), + this.commitment + ) + if (!accountInfo) { + return false + } + const discriminator = this.program.idl.accounts.find( + (account) => account.name === accountName + )!.discriminator + return Buffer.from(discriminator).equals( + accountInfo.data.subarray(0, discriminator.length) + ) + } + + /** + * Return whether a config account is a transfer-hook config. + */ + async isTransferHookConfig( + configAddress: PublicKey | string + ): Promise { + return this.hasAccountDiscriminator( + configAddress, + 'configWithTransferHook' + ) + } + + /** + * Return whether a pool account is a transfer-hook pool. + */ + async isTransferHookPool( + poolAddress: PublicKey | string + ): Promise { + return this.hasAccountDiscriminator(poolAddress, 'transferHookPool') + } + + /** + * Fetch a transfer-hook config account. + */ + async getConfigWithTransferHook( + configAddress: PublicKey | string + ): Promise { + return this.program.account.configWithTransferHook.fetchNullable( + new PublicKey(configAddress), + this.commitment + ) + } + /** * Fetch all pool config accounts. */ @@ -200,6 +254,9 @@ export class StateService { } const configAddress = pool.poolState.config const config = await this.getPoolConfig(configAddress) + if (!config) { + throw new Error(`Config not found: ${configAddress.toString()}`) + } return config.migrationQuoteThreshold } @@ -215,6 +272,11 @@ export class StateService { } const config = await this.getPoolConfig(pool.poolState.config) + if (!config) { + throw new Error( + `Config not found: ${pool.poolState.config.toString()}` + ) + } const quoteReserve = pool.poolState.quoteReserve const migrationThreshold = config.migrationQuoteThreshold @@ -238,6 +300,11 @@ export class StateService { } const config = await this.getPoolConfig(pool.poolState.config) + if (!config) { + throw new Error( + `Config not found: ${pool.poolState.config.toString()}` + ) + } const baseSold = new Decimal( getBaseTokenForSwap( @@ -385,17 +452,23 @@ export class StateService { const partnerUnclaimedBaseFee = pool.poolState.partnerBaseFee const partnerUnclaimedQuoteFee = pool.poolState.partnerQuoteFee - const creatorClaimedBaseFee = creatorTotalTradingBaseFee.sub( - creatorUnclaimedBaseFee + // the program floors the creator share per swap, so the aggregate split is an estimate + const zero = new BN(0) + const creatorClaimedBaseFee = BN.max( + creatorTotalTradingBaseFee.sub(creatorUnclaimedBaseFee), + zero ) - const creatorClaimedQuoteFee = creatorTotalTradingQuoteFee.sub( - creatorUnclaimedQuoteFee + const creatorClaimedQuoteFee = BN.max( + creatorTotalTradingQuoteFee.sub(creatorUnclaimedQuoteFee), + zero ) - const partnerClaimedBaseFee = partnerTotalTradingBaseFee.sub( - partnerUnclaimedBaseFee + const partnerClaimedBaseFee = BN.max( + partnerTotalTradingBaseFee.sub(partnerUnclaimedBaseFee), + zero ) - const partnerClaimedQuoteFee = partnerTotalTradingQuoteFee.sub( - partnerUnclaimedQuoteFee + const partnerClaimedQuoteFee = BN.max( + partnerTotalTradingQuoteFee.sub(partnerUnclaimedQuoteFee), + zero ) return { @@ -404,16 +477,24 @@ export class StateService { unclaimedQuoteFee: creatorUnclaimedQuoteFee, claimedBaseFee: creatorClaimedBaseFee, claimedQuoteFee: creatorClaimedQuoteFee, - totalBaseFee: creatorTotalTradingBaseFee, - totalQuoteFee: creatorTotalTradingQuoteFee, + totalBaseFee: creatorUnclaimedBaseFee.add( + creatorClaimedBaseFee + ), + totalQuoteFee: creatorUnclaimedQuoteFee.add( + creatorClaimedQuoteFee + ), }, partner: { unclaimedBaseFee: partnerUnclaimedBaseFee, unclaimedQuoteFee: partnerUnclaimedQuoteFee, claimedBaseFee: partnerClaimedBaseFee, claimedQuoteFee: partnerClaimedQuoteFee, - totalBaseFee: partnerTotalTradingBaseFee, - totalQuoteFee: partnerTotalTradingQuoteFee, + totalBaseFee: partnerUnclaimedBaseFee.add( + partnerClaimedBaseFee + ), + totalQuoteFee: partnerUnclaimedQuoteFee.add( + partnerClaimedQuoteFee + ), }, } } diff --git a/packages/dynamic-bonding-curve/src/types.ts b/packages/dynamic-bonding-curve/src/types.ts index c9679dfb..5e370f5d 100644 --- a/packages/dynamic-bonding-curve/src/types.ts +++ b/packages/dynamic-bonding-curve/src/types.ts @@ -6,6 +6,7 @@ import type { Program, } from '@coral-xyz/anchor' import type { DynamicBondingCurve } from './idl/dynamic-bonding-curve/idl' +import type { Mint } from '@solana/spl-token' import type { AccountMeta, Keypair, @@ -84,6 +85,9 @@ export type SwapResult = IdlTypes['swapResult'] export type SwapResult2 = IdlTypes['swapResult2'] +export type TransferFeeParameters = + IdlTypes['transferFeeParameters'] + export type TransferHookAccountsInfo = IdlTypes['transferHookAccountsInfo'] @@ -222,6 +226,18 @@ export enum SwapMode { ExactOut = 2, } +export enum TransferFeeWithheldAuthority { + Partner = 0, + Creator = 1, +} + +export enum MigratedTransferFeeAuthorityOption { + Immutable = 0, + RevokeZeroFee = 1, + Creator = 2, + Partner = 3, +} + export const AccountsType = { TransferHookBase: { transferHookBase: {}, @@ -248,6 +264,10 @@ export type CreateConfigParams = Omit< tokenBadge?: PublicKey } +export type CreateConfig2Params = CreateConfigParams & { + transferFeeParameters?: TransferFeeParameters | null +} + export type CreateConfigWithTransferHookParams = CreateConfigParams & { transferHookProgram: PublicKey } @@ -436,6 +456,7 @@ export type CreatePoolWithTransferHookParams = CreatePoolParams & { export type CreateConfigAndPoolParams = CreateConfigParams & { preCreatePoolParam: CreatePoolBaseParams + transferFeeParameters?: TransferFeeParameters | null } export type CreateConfigAndPoolWithFirstBuyParams = @@ -564,6 +585,13 @@ export type SwapQuoteParams = { hasReferral: boolean eligibleForFirstSwapWithMinFee: boolean // only for creator to bundle swap in initialize pool instruction to avoid anti sniper suite fee currentPoint: BN +} & QuoteTransferFees + +export type QuoteTransferFees = { + currentEpoch?: number + baseMint?: Mint + quoteMint?: Mint + baseTransferFeeBasisPoints?: number } export type SwapQuote2Params = { @@ -587,38 +615,18 @@ export type SwapQuote2Params = { swapMode: SwapMode.ExactOut amountOut: BN } -) - -export interface SwapQuoteConfig { - poolFees: { - baseFee: { - cliffFeeNumerator: BN - firstFactor: number - secondFactor: BN - thirdFactor: BN - baseFeeMode: number - } - dynamicFee?: { - initialized?: number - binStep: number - variableFeeControl: number - } | null - } - collectFeeMode: number - sqrtStartPrice: BN - migrationQuoteThreshold: BN - curve: Array<{ sqrtPrice: BN; liquidity: BN }> - migrationSqrtPrice?: BN -} +) & + QuoteTransferFees export type SimulatedQuoteBaseParams = { - config: SwapQuoteConfig + config: ConfigParameters swapBaseForQuote: boolean slippageBps?: number hasReferral?: boolean currentPoint?: BN eligibleForFirstSwapWithMinFee?: boolean -} + quoteTokenFlag?: number +} & QuoteTransferFees export type SimulatedQuoteFromInputAmountParams = SimulatedQuoteBaseParams & { amountIn: BN @@ -808,11 +816,15 @@ export interface FeeMode { export interface SwapQuoteResult extends SwapResult { minimumAmountOut: BN + includedTransferFeeAmountIn: BN + excludedTransferFeeAmountOut: BN } export interface SwapQuote2Result extends SwapResult2 { minimumAmountOut?: BN maximumAmountIn?: BN + includedTransferFeeAmountIn: BN + excludedTransferFeeAmountOut: BN } export interface FeeOnAmountResult { diff --git a/packages/dynamic-bonding-curve/tests/buildCurve.test.ts b/packages/dynamic-bonding-curve/tests/buildCurve.test.ts index 2a8963ee..999c511d 100644 --- a/packages/dynamic-bonding-curve/tests/buildCurve.test.ts +++ b/packages/dynamic-bonding-curve/tests/buildCurve.test.ts @@ -322,10 +322,10 @@ describe('validateCompoundingFeeBps Tests', () => { ).toBe(true) }) - test('Compounding mode with compoundingFeeBps = 0 should fail', () => { + test('Compounding mode with compoundingFeeBps = 0 should pass', () => { expect( validateCompoundingFeeBps(MigratedCollectFeeMode.Compounding, 0) - ).toBe(false) + ).toBe(true) }) test('Compounding mode with compoundingFeeBps > MAX_BASIS_POINT should fail', () => { @@ -369,7 +369,7 @@ describe('validateMigratedPoolFee with Compounding mode', () => { expect(result).toBe(true) }) - test('Compounding mode with compoundingFeeBps = 0 should fail validation', () => { + test('Compounding mode with compoundingFeeBps = 0 should pass validation', () => { const result = validateMigratedPoolFee( { collectFeeMode: MigratedCollectFeeMode.Compounding, @@ -381,10 +381,10 @@ describe('validateMigratedPoolFee with Compounding mode', () => { undefined, 0 ) - expect(result).toBe(false) + expect(result).toBe(true) }) - test('Compounding mode with omitted compoundingFeeBps should fail validation', () => { + test('Compounding mode with omitted compoundingFeeBps should pass validation', () => { const result = validateMigratedPoolFee( { collectFeeMode: MigratedCollectFeeMode.Compounding, @@ -396,7 +396,7 @@ describe('validateMigratedPoolFee with Compounding mode', () => { undefined, undefined ) - expect(result).toBe(false) + expect(result).toBe(true) }) test('Non-compounding mode with non-zero compoundingFeeBps should fail validation', () => { @@ -618,32 +618,27 @@ describe('Migration Fee Option Tests', () => { ) }) - test('Customizable without migratedPoolFee should use defaults', () => { - const curveConfig = buildCurve({ - token: baseTokenParams, - fee: baseFeeParams, - migration: { - migrationOption: MigrationOption.MET_DAMM_V2, - migrationFeeOption: MigrationFeeOption.Customizable, - migrationFee: { - feePercentage: 0, - creatorFeePercentage: 0, + test('Customizable without migratedPoolFee throws', () => { + expect(() => + buildCurve({ + token: baseTokenParams, + fee: baseFeeParams, + migration: { + migrationOption: MigrationOption.MET_DAMM_V2, + migrationFeeOption: MigrationFeeOption.Customizable, + migrationFee: { + feePercentage: 0, + creatorFeePercentage: 0, + }, }, - // no migratedPoolFee provided - }, - liquidityDistribution: baseLiquidityDistribution, - lockedVesting: baseLockedVesting, - activationType: ActivationType.Timestamp, - percentageSupplyOnMigration: 25, - migrationQuoteThreshold: 1, - }) - - expect(curveConfig.migrationFeeOption).toBe( - MigrationFeeOption.Customizable - ) - // should use default params when not provided - expect(curveConfig.migratedPoolFee).toEqual( - DEFAULT_MIGRATED_POOL_FEE_PARAMS + liquidityDistribution: baseLiquidityDistribution, + lockedVesting: baseLockedVesting, + activationType: ActivationType.Timestamp, + percentageSupplyOnMigration: 25, + migrationQuoteThreshold: 1, + }) + ).toThrow( + 'migratedPoolFee.poolFeeBps is required when migrationFeeOption is Customizable' ) }) diff --git a/packages/dynamic-bonding-curve/tests/buildCurveProgramMath.test.ts b/packages/dynamic-bonding-curve/tests/buildCurveProgramMath.test.ts new file mode 100644 index 00000000..b3fff866 --- /dev/null +++ b/packages/dynamic-bonding-curve/tests/buildCurveProgramMath.test.ts @@ -0,0 +1,182 @@ +import { expect, test, describe } from 'vitest' +import BN from 'bn.js' +import Decimal from 'decimal.js' +import { + buildCurve, + buildCurveWithLiquidityWeights, + buildCurveWithMarketCap, + buildCurveWithTwoSegments, + getBaseTokenForSwap, + getMigrationBaseToken, + getMigrationQuoteAmountFromThreshold, + getMigrationThresholdPrice, + getSwapAmountWithBuffer, + getTotalVestingAmount, +} from '../src/helpers' +import { + ActivationType, + BaseFeeMode, + BuildCurveBaseParams, + CollectFeeMode, + ConfigParameters, + MigratedCollectFeeMode, + MigrationFeeOption, + MigrationOption, + TokenAuthorityOption, + TokenDecimal, + TokenType, +} from '../src' + +function programSupplyGap(config: ConfigParameters): { gap: BN } { + const sqrtMigrationPrice = getMigrationThresholdPrice( + config.migrationQuoteThreshold, + config.sqrtStartPrice, + config.curve + ) + const swapBaseAmount = getBaseTokenForSwap( + config.sqrtStartPrice, + sqrtMigrationPrice, + config.curve + ) + const swapBaseAmountBuffer = getSwapAmountWithBuffer( + swapBaseAmount, + config.sqrtStartPrice, + config.curve + ) + const migrationBaseAmount = getMigrationBaseToken( + getMigrationQuoteAmountFromThreshold( + config.migrationQuoteThreshold, + config.migrationFee.feePercentage + ), + sqrtMigrationPrice, + config.migrationOption, + config.migratedPoolFee.collectFeeMode + ) + const minimumSupply = swapBaseAmountBuffer + .add(migrationBaseAmount) + .add(getTotalVestingAmount(config.lockedVesting)) + return { + gap: config.tokenSupply!.preMigrationTokenSupply.sub(minimumSupply), + } +} + +describe('buildCurve math matches program migration math', () => { + const collectFeeModes = [ + MigratedCollectFeeMode.QuoteToken, + MigratedCollectFeeMode.Compounding, + ] + + const baseParams = ( + collectFeeMode: MigratedCollectFeeMode + ): BuildCurveBaseParams => ({ + token: { + tokenType: TokenType.SPLToken, + tokenBaseDecimal: TokenDecimal.SIX, + tokenQuoteDecimal: TokenDecimal.NINE, + tokenAuthorityOption: TokenAuthorityOption.Immutable, + totalTokenSupply: 1_000_000_000, + leftover: 1_000, + }, + fee: { + baseFeeParams: { + baseFeeMode: BaseFeeMode.FeeSchedulerLinear, + feeSchedulerParam: { + startingFeeBps: 100, + endingFeeBps: 100, + numberOfPeriod: 0, + totalDuration: 0, + }, + }, + dynamicFeeEnabled: false, + collectFeeMode: CollectFeeMode.QuoteToken, + creatorTradingFeePercentage: 0, + poolCreationFee: 0, + enableFirstSwapWithMinFee: false, + }, + migration: { + migrationOption: MigrationOption.MET_DAMM_V2, + migrationFeeOption: MigrationFeeOption.Customizable, + migrationFee: { + feePercentage: 3, + creatorFeePercentage: 0, + }, + migratedPoolFee: { + collectFeeMode, + dynamicFee: 0, + poolFeeBps: 100, + }, + }, + liquidityDistribution: { + partnerLiquidityPercentage: 0, + partnerPermanentLockedLiquidityPercentage: 100, + creatorLiquidityPercentage: 0, + creatorPermanentLockedLiquidityPercentage: 0, + }, + lockedVesting: { + totalLockedVestingAmount: 0, + numberOfVestingPeriod: 0, + cliffUnlockAmount: 0, + totalVestingDuration: 0, + cliffDurationFromMigrationTime: 0, + }, + activationType: ActivationType.Timestamp, + }) + + const liquidityWeights = Array.from({ length: 16 }, (_, i) => + new Decimal(1.2).pow(i).toNumber() + ) + + for (const collectFeeMode of collectFeeModes) { + const builders: Array<[string, () => ConfigParameters]> = [ + [ + 'buildCurve', + () => + buildCurve({ + ...baseParams(collectFeeMode), + percentageSupplyOnMigration: 20, + migrationQuoteThreshold: 300, + }), + ], + [ + 'buildCurveWithMarketCap', + () => + buildCurveWithMarketCap({ + ...baseParams(collectFeeMode), + initialMarketCap: 30, + migrationMarketCap: 3_000, + }), + ], + [ + 'buildCurveWithTwoSegments', + () => + buildCurveWithTwoSegments({ + ...baseParams(collectFeeMode), + initialMarketCap: 30, + migrationMarketCap: 3_000, + percentageSupplyOnMigration: 20, + }), + ], + [ + 'buildCurveWithLiquidityWeights', + () => + buildCurveWithLiquidityWeights({ + ...baseParams(collectFeeMode), + initialMarketCap: 30, + migrationMarketCap: 3_000, + liquidityWeights, + }), + ], + ] + + for (const [name, build] of builders) { + test(`${name} reserves the program migration base (collect fee mode ${collectFeeMode})`, () => { + const config = build() + const { gap } = programSupplyGap(config) + const leftover = new BN(1_000).mul(new BN(10 ** 6)) + + expect(gap.gte(new BN(0))).toBe(true) + expect(gap.sub(leftover).abs().lte(new BN(10_000))).toBe(true) + }) + } + } +}) diff --git a/packages/dynamic-bonding-curve/tests/buildCurveWithTwoSegments.test.ts b/packages/dynamic-bonding-curve/tests/buildCurveWithTwoSegments.test.ts index 54210d7c..4e16b924 100644 --- a/packages/dynamic-bonding-curve/tests/buildCurveWithTwoSegments.test.ts +++ b/packages/dynamic-bonding-curve/tests/buildCurveWithTwoSegments.test.ts @@ -116,4 +116,24 @@ describe('buildCurveWithTwoSegments tests', () => { console.log('curve', convertBNToDecimal(config.curve)) expect(config).toBeDefined() }) + + test('keeps a fractional percentageSupplyOnMigration', () => { + const params = { + ...baseParams, + initialMarketCap: 30, + migrationMarketCap: 300, + percentageSupplyOnMigration: 20, + } + const whole = buildCurveWithTwoSegments(params) + const fraction = buildCurveWithTwoSegments({ + ...params, + percentageSupplyOnMigration: 20.5, + }) + expect( + fraction.migrationQuoteThreshold.eq(whole.migrationQuoteThreshold) + ).toBe(false) + expect(fraction.curve[0].liquidity.eq(whole.curve[0].liquidity)).toBe( + false + ) + }) }) diff --git a/packages/dynamic-bonding-curve/tests/createConfig.test.ts b/packages/dynamic-bonding-curve/tests/createConfig.test.ts index 5283bcba..d652e66a 100644 --- a/packages/dynamic-bonding-curve/tests/createConfig.test.ts +++ b/packages/dynamic-bonding-curve/tests/createConfig.test.ts @@ -14,8 +14,10 @@ import { DynamicBondingCurveClient, MigratedCollectFeeMode, getVestingLockedLiquidityBpsAtNSeconds, + MigratedTransferFeeAuthorityOption, MigrationFeeOption, MigrationOption, + TransferFeeWithheldAuthority, TokenDecimal, TokenType, TokenAuthorityOption, @@ -313,6 +315,53 @@ describe('createConfig tests', { timeout: 60000 }, () => { const virtualPool = await dbcClient.state.getPool(pool) expect(virtualPool!.poolState.quoteReserve.gt(new BN(0))).toBe(true) }) + + test('createConfig2 with a base transfer fee', async () => { + const curveConfig = buildTestCurveConfig({ + migratedPoolFee: { + collectFeeMode: MigratedCollectFeeMode.Compounding, + dynamicFee: DammV2DynamicFeeMode.Enabled, + poolFeeBps: 120, + compoundingFeeBps: 0, + baseFeeMode: DammV2BaseFeeMode.FeeTimeSchedulerLinear, + }, + }) + curveConfig.tokenType = TokenType.Token2022 + + const config = Keypair.generate() + const createConfigTx = await dbcClient.partner.createConfig2({ + config: config.publicKey, + feeClaimer: partner.publicKey, + leftoverReceiver: partner.publicKey, + payer: partner.publicKey, + quoteMint: NATIVE_MINT, + transferFeeParameters: { + transferFeeBasisPoints: 250, + withheldAuthority: TransferFeeWithheldAuthority.Partner, + migratedTransferFeeAuthorityOption: + MigratedTransferFeeAuthorityOption.Immutable, + }, + ...curveConfig, + }) + + createConfigTx.feePayer = partner.publicKey + await sendAndConfirmTransaction(connection, createConfigTx, [ + partner, + config, + ]) + + const configState = await dbcClient.state.getPoolConfig( + config.publicKey + ) + expect(configState!.transferFeeBasisPoints).toBe(250) + expect(configState!.transferFeeWithheldAuthority).toBe( + TransferFeeWithheldAuthority.Partner + ) + expect(configState!.migratedTransferFeeAuthorityOption).toBe( + MigratedTransferFeeAuthorityOption.Immutable + ) + expect(configState!.migratedCompoundingFeeBps).toBe(0) + }) }) describe('Locked Liquidity Validation Tests', () => { diff --git a/packages/dynamic-bonding-curve/tests/createConfigAndPool.test.ts b/packages/dynamic-bonding-curve/tests/createConfigAndPool.test.ts index 02f0665e..4ace7333 100644 --- a/packages/dynamic-bonding-curve/tests/createConfigAndPool.test.ts +++ b/packages/dynamic-bonding-curve/tests/createConfigAndPool.test.ts @@ -6,8 +6,25 @@ import { } from '@solana/web3.js' import { beforeEach, describe, expect, test } from 'vitest' import { NATIVE_MINT } from '@solana/spl-token' -import { deriveDbcPoolAddress, DynamicBondingCurveClient } from '../src' +import { + ActivationType, + BaseFeeMode, + buildCurve, + CollectFeeMode, + deriveDbcPoolAddress, + DynamicBondingCurveClient, + MigratedCollectFeeMode, + MigrationFeeOption, + MigrationOption, + TokenAuthorityOption, + TokenDecimal, + TokenType, +} from '../src' import { buildTestCurveConfig, fundSol, LOCALNET_RPC_URL } from './utils/common' +import { + createDbcTokenBadge, + createTransferFeeQuoteMint, +} from './utils/tokenBadgeFlow' const connection = new Connection(LOCALNET_RPC_URL, 'confirmed') const TOKEN_NAME = 'TEST' @@ -69,4 +86,158 @@ describe('createConfigAndPool', { timeout: 60000 }, () => { ).not.toBeNull() expect(await dbcClient.state.getPool(pool)).not.toBeNull() }) + + test('accepts a badged quote mint with a transfer fee without transferFeeParameters', async () => { + const admin = Keypair.generate() + const operator = Keypair.generate() + for (const account of [admin, operator]) { + await fundSol(connection, account.publicKey) + } + const quoteMint = await createTransferFeeQuoteMint( + connection, + admin, + 100 + ) + const tokenBadge = await createDbcTokenBadge( + connection, + admin, + operator, + quoteMint + ) + + const config = Keypair.generate() + const baseMint = Keypair.generate() + const curveConfig = buildCurve({ + token: { + tokenType: TokenType.Token2022, + tokenBaseDecimal: TokenDecimal.SIX, + tokenQuoteDecimal: TokenDecimal.NINE, + tokenAuthorityOption: TokenAuthorityOption.Immutable, + totalTokenSupply: 1_000_000_000, + leftover: 0, + }, + fee: { + baseFeeParams: { + baseFeeMode: BaseFeeMode.FeeSchedulerLinear, + feeSchedulerParam: { + startingFeeBps: 100, + endingFeeBps: 100, + numberOfPeriod: 0, + totalDuration: 0, + }, + }, + dynamicFeeEnabled: false, + collectFeeMode: CollectFeeMode.QuoteToken, + creatorTradingFeePercentage: 0, + poolCreationFee: 0, + enableFirstSwapWithMinFee: false, + }, + migration: { + migrationOption: MigrationOption.MET_DAMM_V2, + migrationFeeOption: MigrationFeeOption.Customizable, + migrationFee: { + feePercentage: 0, + creatorFeePercentage: 0, + }, + migratedPoolFee: { + collectFeeMode: MigratedCollectFeeMode.Compounding, + dynamicFee: 0, + poolFeeBps: 100, + compoundingFeeBps: 0, + }, + }, + liquidityDistribution: { + partnerPermanentLockedLiquidityPercentage: 100, + partnerLiquidityPercentage: 0, + creatorPermanentLockedLiquidityPercentage: 0, + creatorLiquidityPercentage: 0, + }, + lockedVesting: { + totalLockedVestingAmount: 0, + numberOfVestingPeriod: 0, + cliffUnlockAmount: 0, + totalVestingDuration: 0, + cliffDurationFromMigrationTime: 0, + }, + activationType: ActivationType.Timestamp, + percentageSupplyOnMigration: 20, + migrationQuoteThreshold: 50, + }) + + await expect( + dbcClient.partner.createConfig({ + config: Keypair.generate().publicKey, + feeClaimer: partner.publicKey, + leftoverReceiver: partner.publicKey, + payer: poolCreator.publicKey, + quoteMint, + tokenBadge, + ...curveConfig, + }) + ).rejects.toThrow('Quote mint has a non-zero transfer fee') + + const tx = await dbcClient.partner.createConfigAndPool({ + config: config.publicKey, + feeClaimer: partner.publicKey, + leftoverReceiver: partner.publicKey, + payer: poolCreator.publicKey, + quoteMint, + tokenBadge, + ...curveConfig, + preCreatePoolParam: { + baseMint: baseMint.publicKey, + name: TOKEN_NAME, + symbol: TOKEN_SYMBOL, + uri: TOKEN_URI, + poolCreator: poolCreator.publicKey, + }, + }) + tx.feePayer = poolCreator.publicKey + await sendAndConfirmTransaction(connection, tx, [ + poolCreator, + config, + baseMint, + ]) + + const configState = await dbcClient.state.getPoolConfig( + config.publicKey + ) + expect(configState!.quoteMint.equals(quoteMint)).toBe(true) + expect(configState!.transferFeeBasisPoints).toBe(0) + expect( + await dbcClient.state.getPool( + deriveDbcPoolAddress( + quoteMint, + baseMint.publicKey, + config.publicKey + ) + ) + ).not.toBeNull() + }) + + test('accepts a zero-fee quote mint with a revoked transfer fee authority without a badge', async () => { + const quoteMint = await createTransferFeeQuoteMint( + connection, + partner, + 0, + null + ) + const config = Keypair.generate() + + const tx = await dbcClient.partner.createConfig({ + config: config.publicKey, + feeClaimer: partner.publicKey, + leftoverReceiver: partner.publicKey, + payer: partner.publicKey, + quoteMint, + ...buildTestCurveConfig(), + }) + tx.feePayer = partner.publicKey + await sendAndConfirmTransaction(connection, tx, [partner, config]) + + const configState = await dbcClient.state.getPoolConfig( + config.publicKey + ) + expect(configState!.quoteMint.equals(quoteMint)).toBe(true) + }) }) diff --git a/packages/dynamic-bonding-curve/tests/dynamicFees.test.ts b/packages/dynamic-bonding-curve/tests/dynamicFees.test.ts index 4ccaace2..4a349b57 100644 --- a/packages/dynamic-bonding-curve/tests/dynamicFees.test.ts +++ b/packages/dynamic-bonding-curve/tests/dynamicFees.test.ts @@ -5,7 +5,6 @@ import { calculateFeeSchedulerEndingBaseFeeBps, } from '../src' import { expect, test, describe } from 'vitest' -import Decimal from 'decimal.js' describe('getMinBaseFeeBps tests', () => { test('linear fee scheduler - should calculate minimum fee correctly', () => { @@ -28,14 +27,10 @@ describe('getMinBaseFeeBps tests', () => { // linear mode: cliffFeeNumerator - (numberOfPeriod * reductionFactor) const expectedMinFeeNumerator = cliffFeeNumerator - numberOfPeriod * reductionFactor - const expectedMinFeeBps = Math.max( - 0, - (expectedMinFeeNumerator / FEE_DENOMINATOR) * MAX_BASIS_POINT + const expectedMinFeeBps = Math.floor( + (expectedMinFeeNumerator * MAX_BASIS_POINT) / FEE_DENOMINATOR ) - console.log('minBaseFeeBps:', minBaseFeeBps) - console.log('expectedMinFeeBps:', expectedMinFeeBps) - expect(minBaseFeeBps).toBeLessThan(baseFeeBps) expect(minBaseFeeBps).toEqual(expectedMinFeeBps) }) @@ -44,9 +39,9 @@ describe('getMinBaseFeeBps tests', () => { const baseFeeBps = 5000 const cliffFeeNumerator = (baseFeeBps * FEE_DENOMINATOR) / MAX_BASIS_POINT - const numberOfPeriod = 37.5 + const numberOfPeriod = 38 const periodFrequency = 60 - const reductionFactor = 822.5 + const reductionFactor = 823 const baseFeeMode = BaseFeeMode.FeeSchedulerExponential const minBaseFeeBps = calculateFeeSchedulerEndingBaseFeeBps( @@ -57,21 +52,12 @@ describe('getMinBaseFeeBps tests', () => { baseFeeMode ) - // exponential mode: cliffFeeNumerator * (1 - reductionFactor/MAX_BASIS_POINT)^numberOfPeriod - const decayRate = new Decimal(1).sub( - new Decimal(reductionFactor).div(MAX_BASIS_POINT) - ) - const expectedMinFeeNumerator = new Decimal(cliffFeeNumerator) - .mul(decayRate.pow(numberOfPeriod)) - .toNumber() - const expectedMinFeeBps = Math.max( - 0, - (expectedMinFeeNumerator / FEE_DENOMINATOR) * MAX_BASIS_POINT + // Q64 fee for cliff 500_000_000, reduction 823, 38 periods + const expectedMinFeeNumerator = 19124839 + const expectedMinFeeBps = Math.floor( + (expectedMinFeeNumerator * MAX_BASIS_POINT) / FEE_DENOMINATOR ) - console.log('minBaseFeeBps:', minBaseFeeBps) - console.log('expectedMinFeeBps:', expectedMinFeeBps) - expect(minBaseFeeBps).toBeLessThan(baseFeeBps) expect(minBaseFeeBps).toEqual(expectedMinFeeBps) }) diff --git a/packages/dynamic-bonding-curve/tests/fixtures/cp_amm.so b/packages/dynamic-bonding-curve/tests/fixtures/cp_amm.so index 946562cf..3b26839b 100755 Binary files a/packages/dynamic-bonding-curve/tests/fixtures/cp_amm.so and b/packages/dynamic-bonding-curve/tests/fixtures/cp_amm.so differ diff --git a/packages/dynamic-bonding-curve/tests/fixtures/dynamic_bonding_curve.so b/packages/dynamic-bonding-curve/tests/fixtures/dynamic_bonding_curve.so index 7fd83fbd..19dce595 100755 Binary files a/packages/dynamic-bonding-curve/tests/fixtures/dynamic_bonding_curve.so and b/packages/dynamic-bonding-curve/tests/fixtures/dynamic_bonding_curve.so differ diff --git a/packages/dynamic-bonding-curve/tests/getQuoteFrom.test.ts b/packages/dynamic-bonding-curve/tests/getQuoteFrom.test.ts new file mode 100644 index 00000000..2d6f0ed8 --- /dev/null +++ b/packages/dynamic-bonding-curve/tests/getQuoteFrom.test.ts @@ -0,0 +1,191 @@ +import { expect, test, describe } from 'vitest' +import BN from 'bn.js' +import { + ActivationType, + BaseFeeMode, + CollectFeeMode, + MigrationFeeOption, + MigrationOption, + SwapMode, + TokenAuthorityOption, + TokenDecimal, + TokenType, + buildCurve, + getQuoteFromInputAmount, + getQuoteFromOutputAmount, +} from '../src' + +const curveConfig = buildCurve({ + token: { + tokenType: TokenType.SPLToken, + tokenBaseDecimal: TokenDecimal.SIX, + tokenQuoteDecimal: TokenDecimal.NINE, + tokenAuthorityOption: TokenAuthorityOption.Immutable, + totalTokenSupply: 1_000_000_000, + leftover: 0, + }, + fee: { + baseFeeParams: { + baseFeeMode: BaseFeeMode.FeeSchedulerLinear, + feeSchedulerParam: { + startingFeeBps: 100, + endingFeeBps: 100, + numberOfPeriod: 0, + totalDuration: 0, + }, + }, + dynamicFeeEnabled: false, + collectFeeMode: CollectFeeMode.QuoteToken, + creatorTradingFeePercentage: 0, + poolCreationFee: 0, + enableFirstSwapWithMinFee: false, + }, + migration: { + migrationOption: MigrationOption.MET_DAMM_V2, + migrationFeeOption: MigrationFeeOption.FixedBps100, + migrationFee: { + feePercentage: 0, + creatorFeePercentage: 0, + }, + }, + liquidityDistribution: { + partnerLiquidityPercentage: 0, + partnerPermanentLockedLiquidityPercentage: 100, + creatorLiquidityPercentage: 0, + creatorPermanentLockedLiquidityPercentage: 0, + }, + lockedVesting: { + totalLockedVestingAmount: 0, + numberOfVestingPeriod: 0, + cliffUnlockAmount: 0, + totalVestingDuration: 0, + cliffDurationFromMigrationTime: 0, + }, + activationType: ActivationType.Slot, + percentageSupplyOnMigration: 20, + migrationQuoteThreshold: 10, +}) + +describe('getQuoteFrom', () => { + test('quotes exact-in and exact-out from a curve before a pool exists', () => { + const amountIn = new BN(1_000_000_000) + + const exactIn = getQuoteFromInputAmount({ + config: curveConfig, + swapBaseForQuote: false, + swapMode: SwapMode.ExactIn, + amountIn, + slippageBps: 50, + }) + + expect(exactIn.outputAmount.gt(new BN(0))).toBe(true) + expect( + exactIn.minimumAmountOut!.lt(exactIn.excludedTransferFeeAmountOut) + ).toBe(true) + expect(exactIn.includedTransferFeeAmountIn.eq(amountIn)).toBe(true) + + const exactOut = getQuoteFromOutputAmount({ + config: curveConfig, + swapBaseForQuote: false, + amountOut: exactIn.outputAmount, + slippageBps: 50, + }) + + expect(exactOut.outputAmount.eq(exactIn.outputAmount)).toBe(true) + expect( + exactOut.maximumAmountIn!.gte(exactOut.includedFeeInputAmount) + ).toBe(true) + }) + + test('partial fill returns the unfilled input', () => { + const amountIn = new BN('100000000000000000000') + + expect(() => + getQuoteFromInputAmount({ + config: curveConfig, + swapBaseForQuote: false, + swapMode: SwapMode.ExactIn, + amountIn, + }) + ).toThrow('Liquidity in bonding curve is insufficient') + + const partial = getQuoteFromInputAmount({ + config: curveConfig, + swapBaseForQuote: false, + swapMode: SwapMode.PartialFill, + amountIn, + }) + + expect(partial.outputAmount.gt(new BN(0))).toBe(true) + expect(partial.amountLeft.gt(new BN(0))).toBe(true) + expect(partial.includedFeeInputAmount.lt(amountIn)).toBe(true) + }) + + test('a fee scheduler stays at the cliff fee when currentPoint is set', () => { + const schedulerConfig = buildCurve({ + token: { + tokenType: TokenType.SPLToken, + tokenBaseDecimal: TokenDecimal.SIX, + tokenQuoteDecimal: TokenDecimal.NINE, + tokenAuthorityOption: TokenAuthorityOption.Immutable, + totalTokenSupply: 1_000_000_000, + leftover: 0, + }, + fee: { + baseFeeParams: { + baseFeeMode: BaseFeeMode.FeeSchedulerLinear, + feeSchedulerParam: { + startingFeeBps: 500, + endingFeeBps: 100, + numberOfPeriod: 10, + totalDuration: 1000, + }, + }, + dynamicFeeEnabled: false, + collectFeeMode: CollectFeeMode.QuoteToken, + creatorTradingFeePercentage: 0, + poolCreationFee: 0, + enableFirstSwapWithMinFee: false, + }, + migration: { + migrationOption: MigrationOption.MET_DAMM_V2, + migrationFeeOption: MigrationFeeOption.FixedBps100, + migrationFee: { + feePercentage: 0, + creatorFeePercentage: 0, + }, + }, + liquidityDistribution: { + partnerLiquidityPercentage: 0, + partnerPermanentLockedLiquidityPercentage: 100, + creatorLiquidityPercentage: 0, + creatorPermanentLockedLiquidityPercentage: 0, + }, + lockedVesting: { + totalLockedVestingAmount: 0, + numberOfVestingPeriod: 0, + cliffUnlockAmount: 0, + totalVestingDuration: 0, + cliffDurationFromMigrationTime: 0, + }, + activationType: ActivationType.Slot, + percentageSupplyOnMigration: 20, + migrationQuoteThreshold: 10, + }) + const amountIn = new BN(1_000_000_000) + const atZero = getQuoteFromInputAmount({ + config: schedulerConfig, + swapBaseForQuote: false, + amountIn, + }) + const atLaterPoint = getQuoteFromInputAmount({ + config: schedulerConfig, + swapBaseForQuote: false, + amountIn, + currentPoint: new BN(500), + }) + + expect(atLaterPoint.tradingFee.eq(atZero.tradingFee)).toBe(true) + expect(atLaterPoint.outputAmount.eq(atZero.outputAmount)).toBe(true) + }) +}) diff --git a/packages/dynamic-bonding-curve/tests/lockedVesting.test.ts b/packages/dynamic-bonding-curve/tests/lockedVesting.test.ts index c7caf23d..3ce68abb 100644 --- a/packages/dynamic-bonding-curve/tests/lockedVesting.test.ts +++ b/packages/dynamic-bonding-curve/tests/lockedVesting.test.ts @@ -1,4 +1,5 @@ import { + getLiquidityVestingInfoParams, getLockedVestingParams, getTotalVestingAmount, TokenDecimal, @@ -162,3 +163,13 @@ describe('calculateLockedVesting tests', () => { ) }) }) + +test('locked vesting frequency floors the period length', () => { + const result = getLockedVestingParams(100, 13, 0, 100, 0, TokenDecimal.SIX) + expect(result.frequency.toNumber()).toBe(7) +}) + +test('liquidity vesting frequency truncates the period length', () => { + const params = getLiquidityVestingInfoParams(10, 1000, 6, 0, 100) + expect(params.frequency).toBe(16) +}) diff --git a/packages/dynamic-bonding-curve/tests/swapQuote.test.ts b/packages/dynamic-bonding-curve/tests/swapQuote.test.ts index 1e471db6..cbb19141 100644 --- a/packages/dynamic-bonding-curve/tests/swapQuote.test.ts +++ b/packages/dynamic-bonding-curve/tests/swapQuote.test.ts @@ -13,6 +13,8 @@ import { DammV2DynamicFeeMode, deriveDbcPoolAddress, DynamicBondingCurveClient, + getFeeMode, + getIncludedFeeAmount, MigratedCollectFeeMode, MigrationFeeOption, MigrationOption, @@ -20,7 +22,9 @@ import { TokenDecimal, TokenType, TokenAuthorityOption, + TradeDirection, } from '../src' +import { MAX_FEE_NUMERATOR, U64_MAX } from '../src/constants' import { BN } from 'bn.js' import { NATIVE_MINT } from '@solana/spl-token' @@ -276,14 +280,6 @@ describe('swapQuote Tests', { timeout: 60000 }, () => { slippageBps: 50, }) - const quoteFromPoolConfig = dbcClient.pool.getQuoteFromInputAmount({ - config: poolConfigState!, - swapBaseForQuote: false, - swapMode: SwapMode.ExactIn, - amountIn, - slippageBps: 50, - }) - expect(quoteFromBuildCurve.includedFeeInputAmount.eq(amountIn)).toBe( true ) @@ -310,13 +306,6 @@ describe('swapQuote Tests', { timeout: 60000 }, () => { expect( quoteFromBuildCurve.nextSqrtPrice.eq(expected.nextSqrtPrice) ).toBe(true) - - expect(quoteFromPoolConfig.outputAmount.eq(expected.outputAmount)).toBe( - true - ) - expect( - quoteFromPoolConfig.minimumAmountOut!.eq(expected.minimumAmountOut!) - ).toBe(true) }) test('getQuoteFromOutputAmount matches swapQuote2 at launch', async () => { @@ -353,13 +342,6 @@ describe('swapQuote Tests', { timeout: 60000 }, () => { slippageBps: 50, }) - const quoteFromPoolConfig = dbcClient.pool.getQuoteFromOutputAmount({ - config: poolConfigState!, - swapBaseForQuote: false, - amountOut, - slippageBps: 50, - }) - expect( quoteFromBuildCurve.includedFeeInputAmount.eq( expected.includedFeeInputAmount @@ -388,15 +370,6 @@ describe('swapQuote Tests', { timeout: 60000 }, () => { expect( quoteFromBuildCurve.nextSqrtPrice.eq(expected.nextSqrtPrice) ).toBe(true) - - expect( - quoteFromPoolConfig.includedFeeInputAmount.eq( - expected.includedFeeInputAmount - ) - ).toBe(true) - expect( - quoteFromPoolConfig.maximumAmountIn!.eq(expected.maximumAmountIn!) - ).toBe(true) }) test('getQuoteFromInputAmount supports partial fill before pool creation', () => { @@ -409,7 +382,7 @@ describe('swapQuote Tests', { timeout: 60000 }, () => { swapMode: SwapMode.ExactIn, amountIn, }) - ).toThrow('Insufficient Liquidity') + ).toThrow('Liquidity in bonding curve is insufficient') const quote = dbcClient.pool.getQuoteFromInputAmount({ config: curveConfig, @@ -492,3 +465,17 @@ describe('swapQuote Tests', { timeout: 60000 }, () => { expect(result.outputAmount.gt(new BN(0))).toBe(true) }) }) + +describe('swap fee math bounds', () => { + test('getFeeMode rejects an unknown collect fee mode', () => { + expect(() => + getFeeMode(2 as CollectFeeMode, TradeDirection.QuoteToBase, false) + ).toThrow('Invalid collect fee mode') + }) + + test('getIncludedFeeAmount rejects an amount above u64', () => { + expect(() => + getIncludedFeeAmount(new BN(MAX_FEE_NUMERATOR), U64_MAX) + ).toThrow('Type cast failed') + }) +}) diff --git a/packages/dynamic-bonding-curve/tests/tokenBadgeFlow.test.ts b/packages/dynamic-bonding-curve/tests/tokenBadgeFlow.test.ts index d26de0a1..afcf60ee 100644 --- a/packages/dynamic-bonding-curve/tests/tokenBadgeFlow.test.ts +++ b/packages/dynamic-bonding-curve/tests/tokenBadgeFlow.test.ts @@ -4,6 +4,11 @@ import { PublicKey, sendAndConfirmTransaction, } from '@solana/web3.js' +import { + getAccount, + getAssociatedTokenAddressSync, + TOKEN_2022_PROGRAM_ID, +} from '@solana/spl-token' import { beforeEach, describe, expect, test } from 'vitest' import { ActivationType, @@ -15,12 +20,15 @@ import { deriveDbcPoolAuthority, deriveTokenBadgeAddress, DynamicBondingCurveClient, + getCurrentPoint, + getSwapQuoteTransferFees, MigrationFeeOption, MigrationOption, SwapMode, TokenAuthorityOption, TokenDecimal, TokenType, + type SwapQuote2Params, } from '../src' import { BN } from 'bn.js' import { fundSol, LOCALNET_RPC_URL } from './utils/common' @@ -129,22 +137,17 @@ describe( test('rejects createConfig when the quote mint needs a badge and none is passed', async () => { const config = Keypair.generate() - const createConfigTx = await dbcClient.partner.createConfig({ - config: config.publicKey, - feeClaimer: partner.publicKey, - leftoverReceiver: partner.publicKey, - payer: partner.publicKey, - quoteMint, - ...buildBadgedQuoteCurveConfig(), - }) - createConfigTx.feePayer = partner.publicKey await expect( - sendAndConfirmTransaction(connection, createConfigTx, [ - partner, - config, - ]) - ).rejects.toThrow(/Invalid token badge/) + dbcClient.partner.createConfig({ + config: config.publicKey, + feeClaimer: partner.publicKey, + leftoverReceiver: partner.publicKey, + payer: partner.publicKey, + quoteMint, + ...buildBadgedQuoteCurveConfig(), + }) + ).rejects.toThrow(/requires an initialized token badge/) }) test('badges a Token-2022 quote mint, launches on DBC, and migrates to DAMM v2', async () => { @@ -215,6 +218,34 @@ describe( swapAmount ) + const quoteParams: SwapQuote2Params = { + virtualPool: poolState!, + config: configState!, + swapBaseForQuote: false, + hasReferral: false, + eligibleForFirstSwapWithMinFee: false, + currentPoint: await getCurrentPoint( + connection, + configState!.activationType + ), + swapMode: SwapMode.PartialFill, + amountIn: new BN(swapAmount.toString()), + } + expect(() => dbcClient.pool.swapQuote2(quoteParams)).toThrow( + 'quoteMint and currentEpoch are required for a Token-2022 quote mint' + ) + const quoteTransferFees = await getSwapQuoteTransferFees( + connection, + configState! + ) + expect(quoteTransferFees.quoteMint!.address.equals(quoteMint)).toBe( + true + ) + const quote = dbcClient.pool.swapQuote2({ + ...quoteParams, + ...quoteTransferFees, + }) + const swapTx = await dbcClient.pool.swap2({ pool, owner: user.publicKey, @@ -228,6 +259,23 @@ describe( swapTx.feePayer = user.publicKey await sendAndConfirmTransaction(connection, swapTx, [user]) + const userBaseAccount = await getAccount( + connection, + getAssociatedTokenAddressSync( + baseMint.publicKey, + user.publicKey, + false, + TOKEN_2022_PROGRAM_ID + ), + 'confirmed', + TOKEN_2022_PROGRAM_ID + ) + expect( + new BN(userBaseAccount.amount.toString()).eq( + quote.excludedTransferFeeAmountOut + ) + ).toBe(true) + const poolAfterSwap = await dbcClient.state.getPool(pool) expect( poolAfterSwap!.poolState.quoteReserve.gte( diff --git a/packages/dynamic-bonding-curve/tests/transferFee.test.ts b/packages/dynamic-bonding-curve/tests/transferFee.test.ts new file mode 100644 index 00000000..8e2a0877 --- /dev/null +++ b/packages/dynamic-bonding-curve/tests/transferFee.test.ts @@ -0,0 +1,369 @@ +import { Connection, Keypair } from '@solana/web3.js' +import { expect, test, describe } from 'vitest' +import BN from 'bn.js' +import { + constantProductBaseFromQuote, + getMigrationBaseToken, + validateCompoundingMigrationDeposit, + getMigrationThresholdPrice, +} from '../src/helpers/common' +import { + ActivationType, + BaseFeeMode, + buildCurve, + calculateTransferFeeExcludedAmount, + calculateTransferFeeIncludedAmount, + CollectFeeMode, + DynamicBondingCurveClient, + MigratedCollectFeeMode, + MigratedTransferFeeAuthorityOption, + MigrationFeeOption, + MigrationOption, + TokenAuthorityOption, + TokenDecimal, + TokenType, + TransferFeeWithheldAuthority, + resolveSwapTransferFees, + validateConfigParameters, + validateTransferFeeParameters, + type PoolConfig, +} from '../src' +import type { Mint } from '@solana/spl-token' +import { MAX_BASE_TRANSFER_FEE_BPS, U64_MAX } from '../src/constants' + +const baseCurve = buildCurve({ + token: { + tokenType: TokenType.Token2022, + tokenBaseDecimal: TokenDecimal.SIX, + tokenQuoteDecimal: TokenDecimal.NINE, + tokenAuthorityOption: TokenAuthorityOption.Immutable, + totalTokenSupply: 1_000_000_000, + leftover: 0, + }, + fee: { + baseFeeParams: { + baseFeeMode: BaseFeeMode.FeeSchedulerLinear, + feeSchedulerParam: { + startingFeeBps: 100, + endingFeeBps: 100, + numberOfPeriod: 0, + totalDuration: 0, + }, + }, + dynamicFeeEnabled: false, + collectFeeMode: CollectFeeMode.QuoteToken, + creatorTradingFeePercentage: 0, + poolCreationFee: 0, + enableFirstSwapWithMinFee: false, + }, + migration: { + migrationOption: MigrationOption.MET_DAMM_V2, + migrationFeeOption: MigrationFeeOption.Customizable, + migrationFee: { + feePercentage: 0, + creatorFeePercentage: 0, + }, + migratedPoolFee: { + collectFeeMode: MigratedCollectFeeMode.Compounding, + dynamicFee: 0, + poolFeeBps: 100, + compoundingFeeBps: 0, + }, + }, + liquidityDistribution: { + partnerPermanentLockedLiquidityPercentage: 100, + partnerLiquidityPercentage: 0, + creatorPermanentLockedLiquidityPercentage: 0, + creatorLiquidityPercentage: 0, + }, + lockedVesting: { + totalLockedVestingAmount: 0, + numberOfVestingPeriod: 0, + cliffUnlockAmount: 0, + totalVestingDuration: 0, + cliffDurationFromMigrationTime: 0, + }, + activationType: ActivationType.Timestamp, + percentageSupplyOnMigration: 20, + migrationQuoteThreshold: 50, +}) + +describe('transfer fee math', () => { + const transferFee = { + transferFeeBasisPoints: 100, + maximumFee: U64_MAX, + } + + test('excludes the fee from an included amount', () => { + const result = calculateTransferFeeExcludedAmount( + transferFee, + new BN(1_000_000) + ) + expect(result.transferFee.eqn(10_000)).toBe(true) + expect(result.amount.eqn(990_000)).toBe(true) + }) + + test('caps the fee at maximumFee', () => { + const result = calculateTransferFeeExcludedAmount( + { transferFeeBasisPoints: 100, maximumFee: new BN(1) }, + new BN(1_000_000) + ) + expect(result.transferFee.eqn(1)).toBe(true) + expect(result.amount.eqn(999_999)).toBe(true) + }) + + test('grosses an excluded amount back up and verifies the inverse', () => { + const excluded = calculateTransferFeeExcludedAmount( + transferFee, + new BN(1_000_000) + ) + const included = calculateTransferFeeIncludedAmount( + transferFee, + excluded.amount + ) + expect(included.transferFee.eq(excluded.transferFee)).toBe(true) + const checked = calculateTransferFeeExcludedAmount( + transferFee, + included.amount + ) + expect(checked.amount.eq(excluded.amount)).toBe(true) + }) + + test('rejects an included amount above u64', () => { + expect(() => + calculateTransferFeeIncludedAmount(transferFee, U64_MAX) + ).toThrow('Math overflow') + expect(() => + calculateTransferFeeIncludedAmount( + { transferFeeBasisPoints: 10_000, maximumFee: new BN(1) }, + U64_MAX + ) + ).toThrow('Math overflow') + }) + + test('leaves the amount unchanged when there is no fee', () => { + const amount = new BN(123) + expect( + calculateTransferFeeExcludedAmount(null, amount).amount.eq(amount) + ).toBe(true) + expect( + calculateTransferFeeIncludedAmount(null, amount).amount.eq(amount) + ).toBe(true) + }) +}) + +describe('compounding migration deposit', () => { + const sqrtPrice = new BN('583337266871351588') + const quoteAmount = new BN('50000000000') + + test('uses constant product for a compounding migration base', () => { + const base = getMigrationBaseToken( + quoteAmount, + sqrtPrice, + MigrationOption.MET_DAMM_V2, + MigratedCollectFeeMode.Compounding + ) + expect( + base.eq(constantProductBaseFromQuote(quoteAmount, sqrtPrice)) + ).toBe(true) + }) + + test('accepts a deposit whose price stays within 1% after the base fee', () => { + const migrationSqrtPrice = getMigrationThresholdPrice( + baseCurve.migrationQuoteThreshold, + baseCurve.sqrtStartPrice, + baseCurve.curve + ) + expect(() => + validateCompoundingMigrationDeposit({ + migrationQuoteThreshold: baseCurve.migrationQuoteThreshold, + migrationFeePercentage: baseCurve.migrationFee.feePercentage, + migrationSqrtPrice, + baseTransferFee: { + transferFeeBasisPoints: 250, + maximumFee: U64_MAX, + }, + }) + ).not.toThrow() + }) + + test('rejects a compounding deposit below dead liquidity', () => { + expect(() => + validateCompoundingMigrationDeposit({ + migrationQuoteThreshold: new BN(1), + migrationFeePercentage: 0, + migrationSqrtPrice: sqrtPrice, + }) + ).toThrow() + }) +}) + +describe('transfer fee quotes', () => { + const client = new DynamicBondingCurveClient( + new Connection('http://127.0.0.1:8899'), + 'confirmed' + ) + const amountIn = new BN(1_000_000_000) + + test('a base transfer fee reduces the quote bought with quote tokens', () => { + const noFee = client.pool.getQuoteFromInputAmount({ + config: baseCurve, + swapBaseForQuote: false, + amountIn, + }) + const withFee = client.pool.getQuoteFromInputAmount({ + config: baseCurve, + swapBaseForQuote: false, + amountIn, + baseTransferFeeBasisPoints: 250, + }) + + expect(withFee.includedFeeInputAmount.eq(amountIn)).toBe(true) + expect(withFee.includedTransferFeeAmountIn.eq(amountIn)).toBe(true) + expect( + withFee.excludedTransferFeeAmountOut.lt(withFee.outputAmount) + ).toBe(true) + expect( + withFee.excludedTransferFeeAmountOut.lt(noFee.outputAmount) + ).toBe(true) + expect( + withFee.minimumAmountOut!.eq(withFee.excludedTransferFeeAmountOut) + ).toBe(true) + }) + + test('exact out round trips through the base transfer fee', () => { + const exactIn = client.pool.getQuoteFromInputAmount({ + config: baseCurve, + swapBaseForQuote: false, + amountIn, + baseTransferFeeBasisPoints: 250, + }) + const exactOut = client.pool.getQuoteFromOutputAmount({ + config: baseCurve, + swapBaseForQuote: false, + amountOut: exactIn.excludedTransferFeeAmountOut, + baseTransferFeeBasisPoints: 250, + }) + const roundTrip = client.pool.getQuoteFromInputAmount({ + config: baseCurve, + swapBaseForQuote: false, + amountIn: exactOut.includedTransferFeeAmountIn, + baseTransferFeeBasisPoints: 250, + }) + + expect( + exactOut.excludedTransferFeeAmountOut.eq( + exactIn.excludedTransferFeeAmountOut + ) + ).toBe(true) + expect( + roundTrip.excludedTransferFeeAmountOut.gte( + exactIn.excludedTransferFeeAmountOut + ) + ).toBe(true) + }) + + test('a Token-2022 quote mint requires quoteMint', () => { + const quoteMint = Keypair.generate().publicKey + const config = { + quoteMint, + quoteTokenFlag: TokenType.Token2022, + transferFeeBasisPoints: 0, + } as unknown as PoolConfig + + expect(() => resolveSwapTransferFees(config, false)).toThrow( + 'quoteMint and currentEpoch are required for a Token-2022 quote mint' + ) + expect(() => + resolveSwapTransferFees(config, false, { + quoteMint: { + address: Keypair.generate().publicKey, + } as Mint, + currentEpoch: 0, + }) + ).toThrow('quoteMint does not match config.quoteMint') + expect( + resolveSwapTransferFees( + { ...config, quoteTokenFlag: TokenType.SPLToken }, + false + ) + ).toEqual({ input: null, output: null }) + }) +}) + +describe('transfer fee config validation', () => { + const transferFeeParameters = { + transferFeeBasisPoints: 250, + withheldAuthority: TransferFeeWithheldAuthority.Creator, + migratedTransferFeeAuthorityOption: + MigratedTransferFeeAuthorityOption.Partner, + } + + test('accepts a token2022 base fee within the cap', () => { + expect(() => + validateTransferFeeParameters( + transferFeeParameters, + TokenType.Token2022 + ) + ).not.toThrow() + }) + + test('rejects a base fee above 10%', () => { + expect(() => + validateTransferFeeParameters( + { + ...transferFeeParameters, + transferFeeBasisPoints: MAX_BASE_TRANSFER_FEE_BPS + 1, + }, + TokenType.Token2022 + ) + ).toThrow('Invalid transfer fee parameters') + }) + + test('rejects a base fee on an SPL mint', () => { + expect(() => + validateTransferFeeParameters( + transferFeeParameters, + TokenType.SPLToken + ) + ).toThrow('Base transfer fee requires token type Token2022') + }) + + test('rejects authority fields when the fee is zero', () => { + expect(() => + validateTransferFeeParameters( + { + transferFeeBasisPoints: 0, + withheldAuthority: TransferFeeWithheldAuthority.Creator, + migratedTransferFeeAuthorityOption: + MigratedTransferFeeAuthorityOption.Immutable, + }, + TokenType.Token2022 + ) + ).toThrow('Invalid transfer fee parameters') + }) + + test('requires constant supply, no vesting, customizable migration, and compounding', () => { + const leftoverReceiver = Keypair.generate().publicKey + expect(() => + validateConfigParameters( + { ...baseCurve, leftoverReceiver }, + { + transferFeeParameters, + quoteMintHasTransferFee: false, + } + ) + ).not.toThrow() + + expect(() => + validateConfigParameters( + { + ...baseCurve, + tokenSupply: null, + leftoverReceiver, + }, + { transferFeeParameters, quoteMintHasTransferFee: false } + ) + ).toThrow('Transfer fee configs require a constant token supply') + }) +}) diff --git a/packages/dynamic-bonding-curve/tests/transferHook.test.ts b/packages/dynamic-bonding-curve/tests/transferHook.test.ts index 9f9b0464..744d3485 100644 --- a/packages/dynamic-bonding-curve/tests/transferHook.test.ts +++ b/packages/dynamic-bonding-curve/tests/transferHook.test.ts @@ -7,7 +7,9 @@ import { TransactionInstruction, } from '@solana/web3.js' import { + getAccount, getMint, + getOrCreateAssociatedTokenAccount, getTransferHook, NATIVE_MINT, TOKEN_2022_PROGRAM_ID, @@ -16,9 +18,9 @@ import BN from 'bn.js' import { beforeEach, describe, expect, test } from 'vitest' import { bpsToFeeNumerator, - createDbcProgram, deriveDbcPoolAddress, deriveDbcPoolAuthority, + DYNAMIC_BONDING_CURVE_PROGRAM_ID, DynamicBondingCurveClient, FEE_DENOMINATOR, SwapMode, @@ -26,6 +28,7 @@ import { type ConfigParameters, type Swap2Params, } from '../src' +import { createDbcProgram } from '../src/helpers/createProgram' import { buildTestCurveConfig, fundSol, LOCALNET_RPC_URL } from './utils/common' import { getCounterValue, @@ -180,6 +183,90 @@ describe('transfer hook SDK tests', { timeout: 90000 }, () => { expect(config.publicKey).toBeDefined() }) + test('swap2WithTransferHook skips base referral hook accounts when the referral fee is in quote', async () => { + const { pool } = await createPoolAndHookAccounts() + const referralTokenAccount = await getOrCreateAssociatedTokenAccount( + connection, + user, + NATIVE_MINT, + Keypair.generate().publicKey + ) + const params: Swap2Params = { + swapMode: SwapMode.ExactIn, + swapBaseForQuote: false, + amountIn: new BN(1_000_000), + minimumAmountOut: new BN(0), + owner: user.publicKey, + pool, + referralTokenAccount: null, + payer: user.publicKey, + } + + const [swapTx, referralSwapTx] = await Promise.all([ + dbcClient.pool.swap2WithTransferHook(params), + dbcClient.pool.swap2WithTransferHook({ + ...params, + referralTokenAccount: referralTokenAccount.address, + }), + ]) + expect(getSwapInstruction(referralSwapTx).keys.length).toBe( + getSwapInstruction(swapTx).keys.length + ) + + referralSwapTx.feePayer = user.publicKey + await sendAndConfirmTransaction(connection, referralSwapTx, [user]) + + const referralAccount = await getAccount( + connection, + referralTokenAccount.address + ) + expect(referralAccount.amount > BigInt(0)).toBe(true) + }) + + test('rejects standard builders for transfer-hook configs and pools', async () => { + const { config, pool } = await createPoolAndHookAccounts() + const createPoolParams = { + baseMint: Keypair.generate().publicKey, + config: config.publicKey, + name: TOKEN_NAME, + symbol: TOKEN_SYMBOL, + uri: TOKEN_URI, + payer: poolCreator.publicKey, + poolCreator: poolCreator.publicKey, + } + + await expect( + dbcClient.creator.createPool(createPoolParams) + ).rejects.toThrow('Config uses a transfer hook') + await expect( + dbcClient.creator.createPoolWithTransferHook({ + ...createPoolParams, + transferHookProgram: Keypair.generate().publicKey, + }) + ).rejects.toThrow('Transfer hook program does not match config') + await expect( + dbcClient.pool.swap2({ + swapMode: SwapMode.ExactIn, + swapBaseForQuote: false, + amountIn: new BN(1), + minimumAmountOut: new BN(0), + owner: user.publicKey, + pool, + referralTokenAccount: null, + payer: user.publicKey, + }) + ).rejects.toThrow('Pool uses a transfer hook') + await expect( + dbcClient.partner.claimPartnerTradingFee({ + feeClaimer: partner.publicKey, + payer: partner.publicKey, + pool, + maxBaseAmount: new BN(1), + maxQuoteAmount: new BN(1), + }) + ).rejects.toThrow('Pool uses a transfer hook') + }) + test('createPoolWithFirstBuyWithTransferHook bundles pool init, extra metas, and first buy', async () => { const { config, curveConfig } = await createTransferHookConfig({ enableFirstSwapWithMinFee: true, @@ -457,6 +544,12 @@ function derivePool(config: PublicKey, baseMint: PublicKey): PublicKey { return deriveDbcPoolAddress(NATIVE_MINT, baseMint, config) } +function getSwapInstruction(transaction: Transaction): TransactionInstruction { + return transaction.instructions.find((instruction) => + instruction.programId.equals(DYNAMIC_BONDING_CURVE_PROGRAM_ID) + )! +} + function insertAfterFirstInstruction( transaction: Transaction, instructions: TransactionInstruction[] diff --git a/packages/dynamic-bonding-curve/tests/utils/tokenBadgeFlow.ts b/packages/dynamic-bonding-curve/tests/utils/tokenBadgeFlow.ts index 390bc57e..adc0a2b8 100644 --- a/packages/dynamic-bonding-curve/tests/utils/tokenBadgeFlow.ts +++ b/packages/dynamic-bonding-curve/tests/utils/tokenBadgeFlow.ts @@ -10,6 +10,7 @@ import { createAssociatedTokenAccountIdempotentInstruction, createInitializeMint2Instruction, createInitializePermanentDelegateInstruction, + createInitializeTransferFeeConfigInstruction, createMintToInstruction, ExtensionType, getAssociatedTokenAddressSync, @@ -20,8 +21,6 @@ import BN from 'bn.js' import { ActivationType, CollectFeeMode, - createDbcProgram, - createDammV2Program, DAMM_V2_PROGRAM_ID, DammV2BaseFeeMode, deriveDbcPoolAuthority, @@ -30,6 +29,10 @@ import { MAX_SQRT_PRICE, MIN_SQRT_PRICE, } from '../../src' +import { + createDammV2Program, + createDbcProgram, +} from '../../src/helpers/createProgram' // dynamic-bonding-curve/src/state/operator.rs enum DbcOperatorPermission { @@ -101,6 +104,46 @@ export async function createStockQuoteMint( return mintKeypair.publicKey } +export async function createTransferFeeQuoteMint( + connection: Connection, + payer: Keypair, + transferFeeBasisPoints: number, + transferFeeConfigAuthority: PublicKey | null = payer.publicKey +): Promise { + const mintKeypair = Keypair.generate() + const mintLen = getMintLen([ExtensionType.TransferFeeConfig]) + const lamports = await connection.getMinimumBalanceForRentExemption(mintLen) + + const tx = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mintKeypair.publicKey, + space: mintLen, + lamports, + programId: TOKEN_2022_PROGRAM_ID, + }), + createInitializeTransferFeeConfigInstruction( + mintKeypair.publicKey, + transferFeeConfigAuthority, + payer.publicKey, + transferFeeBasisPoints, + BigInt('18446744073709551615'), + TOKEN_2022_PROGRAM_ID + ), + createInitializeMint2Instruction( + mintKeypair.publicKey, + 9, + payer.publicKey, + null, + TOKEN_2022_PROGRAM_ID + ) + ) + tx.feePayer = payer.publicKey + await sendAndConfirmTransaction(connection, tx, [payer, mintKeypair]) + + return mintKeypair.publicKey +} + export async function mintToken2022( connection: Connection, payer: Keypair,