Pool Manager
The poolmanager module exists as a swap entrypoint for any pool model that exists on the chain. The poolmanager module is responsible for routing swaps across various pools. It also performs pool-id management for any onchain pool.
The user-stories for this module follow:
As a user, I would like to have a unified entrypoint for my swaps regardless of the underlying pool implementation so that I don't need to reason about API complexity
As a user, I would like the pool management to be unified so that I don't have to reason about additional complexity stemming from divergent pool sources.
Osmosis has three pool-storage modules: x/gamm, x/concentrated-liquidity,
and x/cosmwasmpool.
To avoid fragmenting swap and pool creation entrypoints and duplicating their boilerplate logic,
we define a poolmanager module. Its purpose is twofold:
- Handle pool creation
- Assign ids to pools
- Store the mapping from pool id to its swap module (
gamm,concentrated-liquidity, orcosmwasmpool) - Propagate the execution to the appropriate module depending on the pool type.
- Note, that pool creation messages are received by the pool model's message server.
Each module's message server then calls the
x/poolmanagerkeeper methodCreatePool.
- Handle swaps
- Cover & share multihop logic
- Propagate intra-pool swaps to the appropriate module depending on the pool type.
- Contrary to pool creation, swap messages are received by the
x/poolmanagermessage server.
Let's consider pool creation and swaps separately and in more detail.
Pool Creation & Id Management
To make sure that pool ids are unique across all pool modules, we unify pool id management
in the poolmanager.
When a call to CreatePool keeper method is received, we get the next pool id from the module
storage, assign it to the new pool, and propagate execution to the appropriate pool module.
Pool creation messages implement the
CreatePoolMsg interface.
Each pool model implements the interfaces needed by Pool Manager. CosmWasm pools
are created through x/cosmwasmpool, while Pool Manager assigns their pool IDs
and routes swaps to the CosmWasm pool keeper.
Note the PoolType type. This is an enumeration of all supported pool types.
We proto-generate this enumeration:
// proto/osmosis/poolmanager/v1beta1/module_route.proto
// generates to x/poolmanager/types/module_route.pb.go
// PoolType is an enumeration of all supported pool types.
enum PoolType {
option (gogoproto.goproto_enum_prefix) = false;
// Balancer is the standard xy=k curve. Its pool model is defined in x/gamm.
Balancer = 0;
// Stableswap is the Solidly cfmm stable swap curve. Its pool model is defined
// in x/gamm.
StableSwap = 1;
// Concentrated is the pool model specific to concentrated liquidity. It is
// defined in x/concentrated-liquidity.
Concentrated = 2;
// CosmWasm is the pool model specific to CosmWasm. It is defined in
// x/cosmwasmpool.
CosmWasm = 3;
}
Let's begin by considering the execution flow of the pool creation message.
Assume balancer pool is being created.
-
CreatePoolMsgis received by thex/gammmessage server. -
CreatePoolkeeper method is called frompoolmanager, propagating the appropriate implementation of theCreatePoolMsginterface.
// x/poolmanager/create_pool.go CreatePool(...)
// CreatePool attempts to create a pool returning the newly created pool ID or
// an error upon failure. The pool creation fee is used to fund the community
// pool. It will create a dedicated module account for the pool and sends the
// initial liquidity to the created module account.
//
// After the initial liquidity is sent to the pool's account, this function calls an
// InitializePool function from the source module. That module is responsible for:
// - saving the pool into its own state
// - Minting LP shares to pool creator
// - Setting metadata for the shares
func (k Keeper) CreatePool(ctx sdk.Context, msg types.CreatePoolMsg) (uint64, error) {
...
}
-
The keeper utilizes
CreatePoolMsginterface methods to execute the logic specific to each pool type. -
Lastly,
poolmanager.CreatePoolroutes the execution to the appropriate module.
The propagation to the desired module is ensured by the routing table stored in memory in the poolmanager keeper.
// x/poolmanager/keeper.go NewKeeper(...)
func NewKeeper(...) *Keeper {
...
routesMap := map[types.PoolType]types.PoolModuleI{
types.Balancer: gammKeeper,
types.Stableswap: gammKeeper,
types.Concentrated: concentratedKeeper,
types.CosmWasm: cosmwasmpoolKeeper,
}
return &Keeper{..., routes: routesMap}
}
MsgCreatePool interface defines the following method: GetPoolType() PoolType
As a result, poolmanagerkeeper.CreatePool can route the execution to the appropriate module in
the following way:
// x/poolmanager/create_pool.go CreatePool(...)
swapModule := k.routes[msg.GetPoolType()]
if err := swapModule.InitializePool(ctx, pool, sender); err != nil {
return 0, err
}
The selected swap module can be the gamm, concentrated-liquidity, or
cosmwasmpool keeper.
All three modules implement the PoolModuleI interface:
// x/poolmanager/types/expected_keepers.go
type PoolModuleI interface {
InitializePool(ctx sdk.Context, pool PoolI, creatorAddress sdk.AccAddress) error
GetPool(ctx sdk.Context, poolId uint64) (PoolI, error)
GetPools(ctx sdk.Context) ([]PoolI, error)
...
}
As a result, the poolmanager module propagates core execution to the appropriate swap module.
Lastly, the poolmanager keeper stores a mapping from the pool id to the pool type.
This mapping is going to be necessary for knowing where to route the swap messages.
To achieve this, we create the following store index:
// x/poolmanager/types/keys.go
var (
...
SwapModuleRouterPrefix = []byte{0x02}
)
// N.B.: we proto-generate this struct. However, the proto
// definition is omitted for brevity.
type ModuleRoute struct {
PoolType PoolType
}
// FormatModuleRouteKey serializes pool id with appropriate prefix into bytes.
func FormatModuleRouteKey(poolId uint64) []byte {
return []byte(fmt.Sprintf("%s%d", SwapModuleRouterPrefix, poolId))
}
// ParseModuleRouteFromBz parses the raw bytes into ModuleRoute.
// Returns error if fails to parse or if the bytes are empty.
func ParseModuleRouteFromBz(bz []byte) (ModuleRoute, error) {
// parsing logic
}
Swaps
There are 4 swap messages:
MsgSwapExactAmountInMsgSwapExactAmountOutMsgSplitRouteSwapExactAmountInMsgSplitRouteSwapExactAmountOut
Between, MsgSwapExactAmountIn and MsgSwapExactAmountOut, the implementation of routing is similar. We only focus on MsgSwapExactAmountIn below.
MsgSplitRouteSwapExactAmountIn and MsgSplitRouteSwapExactAmountOut support split routes where for each split route they call the respective
MsgSwapExactAmountIn or MsgSwapExactAmountOut message. When using the split routes, the slippage protection is disabled on the per-route basis.
For swap exact amount in, we provide zero for the min amount out. For swap exact amount out, we provide the max amount in which is 1 << 256 - 1.
Read more about route splitting in the "Route Splitting" section.
Once the message is received, it calls RouteExactAmountIn
// x/poolmanager/router.go RouteExactAmountIn(...)
// RouteExactAmountIn defines the input denom and input amount for the first pool,
// the output of the first pool is chained as the input for the next routed pool
// transaction succeeds when final amount out is greater than tokenOutMinAmount defined.
func (k Keeper) RouteExactAmountIn(
ctx sdk.Context,
sender sdk.AccAddress,
route []types.SwapAmountInRoute,
tokenIn sdk.Coin,
tokenOutMinAmount osmomath.Int,
) (tokenOutAmount osmomath.Int, err error) {
}
Essentially, the method iterates over the routes and calls a SwapExactAmountIn method
for each, subsequently updating the inter-pool swap state.
The routing works by looking up the pool's type from the SwapModuleRouterPrefix index,
resolving that type through the routes mapping, and calling the SwapExactAmountIn
method of the appropriate module. GetPoolModuleAndPool performs both the module lookup
and the pool fetch:
// x/poolmanager/router.go SwapExactAmountIn(...)
swapModule, pool, err := k.GetPoolModuleAndPool(ctx, poolId)
_, err = swapModule.SwapExactAmountIn(ctx, sender, pool, tokenIn, tokenOutDenom, tokenOutMinAmount, spreadFactor)
- note that error checks and other details are omitted for brevity.
Similar to pool creation logic, we are able to call SwapExactAmountIn on any of the swap
modules by implementing the PoolModuleI interface:
// x/poolmanager/types/expected_keepers.go
type PoolModuleI interface {
...
SwapExactAmountIn(
ctx sdk.Context,
sender sdk.AccAddress,
pool PoolI,
tokenIn sdk.Coin,
tokenOutDenom string,
tokenOutMinAmount osmomath.Int,
spreadFactor osmomath.Dec,
) (osmomath.Int, error)
}
During the process of swapping a specific asset, the token the user is
putting into the pool is denoted as tokenIn, while the token that
would be returned to the user, the asset that is being swapped for,
after the swap is denoted as tokenOut throughout the module.
For example, in the context of balancer pools, given a tokenIn, the
following calculations are done to calculate how many tokens are to be
swapped into and removed from the pool:
tokenBalanceOut * [1 - { tokenBalanceIn / (tokenBalanceIn + (1 - spreadFactor) * tokenAmountIn)} ^ (tokenWeightIn / tokenWeightOut)]
The calculation is also able to be reversed, the case where user
provides tokenOut. The calculation for the amount of tokens that the
user should be putting in is done through the following formula:
tokenBalanceIn * [{tokenBalanceOut / (tokenBalanceOut - tokenAmountOut)} ^ (tokenWeightOut / tokenWeightIn) - 1] / (1 - spreadFactor)
Existing Swap types:
- SwapExactAmountIn
- SwapExactAmountOut
Messages
The active message service is defined in the Pool Manager transaction proto.
| Message | Purpose |
|---|---|
MsgSwapExactAmountIn | Executes a route with an exact input and minimum final output. |
MsgSwapExactAmountOut | Executes a route with an exact output and maximum total input. |
MsgSplitRouteSwapExactAmountIn | Splits an exact-input swap across multiple routes. |
MsgSplitRouteSwapExactAmountOut | Splits an exact-output swap across multiple routes. |
MsgSetDenomPairTakerFee | Sets or removes directional taker-fee overrides. The signer must be an authorized admin address, unless the change is executed by governance. |
MsgSetTakerFeeShareAgreementForDenom | Creates, updates, or removes the fee-share agreement for a denom. This is an authority-controlled message. |
MsgSetRegisteredAlloyedPool | Registers or removes an alloyed pool used for taker-fee revenue sharing. This is an authority-controlled message. |
Multi-Hop
All tokens are swapped using a multi-hop mechanism. That is, all swaps are routed via the most cost-efficient way, swapping in and out from multiple pools in the process. The most cost-efficient route is determined offline and the list of the pools is provided externally, by user, during the broadcasting of the swapping transaction. At the moment of execution, the provided route may not be the most cost-efficient one anymore.
Multi-hop routing implementation
Route Splitting
Each route can be thought of as a separate multi-hop swap.
Splitting swaps across multiple pools for the same token pair can be beneficial for several reasons, primarily relating to reduced slippage, price impact, and potentially lower spreads.
Here's a detailed explanation of these advantages:
-
Reduced slippage: When a large trade is executed in a single pool, it can be significantly affected if someone else executes a large swap against that pool.
-
Lower price impact: When executing a large trade in a single pool, the price impact can be substantial, leading to a less favorable exchange rate for the trader. By splitting the swap across multiple pools, the price impact in each pool is minimized, resulting in a better overall exchange rate.
-
Improved liquidity utilization: Different pools may have varying levels of liquidity, spreads, and price curves. By splitting swaps across multiple pools, the router can utilize liquidity from various sources, allowing for more efficient execution of trades. This is particularly useful when the liquidity in a single pool is not sufficient to handle a large trade or when the price curve of one pool becomes less favorable as the trade size increases.
-
Potentially lower spreads: In some cases, splitting swaps across multiple pools may result in lower overall spreads. This can happen when different pools have different spread structures, or when the total spread paid across multiple pools is lower than the spread for executing the entire trade in a single pool with higher slippage.
Note, that the actual split happens off-chain. The router is only responsible for executing the swaps in the order and quantities of token in provided by the routes.
Taker Fees
Pool Manager charges a taker fee in addition to each pool's spread factor. The
fee is selected by the ordered input and output denom pair. A directional
override takes precedence over the default taker fee, so denomA -> denomB and
denomB -> denomA can have different fees. Addresses in the reduced-fee
whitelist bypass the standard taker fee.
Collected fees are tracked by denom. The module's epoch hooks distribute OSMO and non-OSMO fees between staking rewards, the community pool, and burning according to separate configured percentages. Non-whitelisted community-pool assets are swapped to the configured community-pool intermediary denom before distribution. Staking rewards can be smoothed across multiple daily epochs.
Taker-Fee Sharing
An authority-managed agreement can associate a denom with a skim percentage and recipient address. When that denom appears in a swap route, the configured share of the route's taker fees accrues for the recipient and is paid at the epoch boundary.
Alloyed CosmWasm pools can be registered for taker-fee sharing. Registration tracks the alloyed denom, contract address, pool ID, and the current composition of fee-share denoms represented by the pool.
Parameters
The parameter definitions are in the Pool Manager genesis proto.
| Parameter | Purpose |
|---|---|
pool_creation_fee | Coins charged when a module creates a pool through Pool Manager. |
taker_fee_params.default_taker_fee | Fee used when a directional denom-pair override is not set. |
taker_fee_params.osmo_taker_fee_distribution | OSMO fee split between staking rewards, community pool, and burning. |
taker_fee_params.non_osmo_taker_fee_distribution | Non-OSMO fee split between staking rewards, community pool, and burning. |
taker_fee_params.admin_addresses | Accounts allowed to set directional taker-fee overrides directly. |
taker_fee_params.community_pool_denom_to_swap_non_whitelisted_assets_to | Intermediary denom used before non-whitelisted assets are sent to the community pool. |
taker_fee_params.reduced_fee_whitelist | Accounts allowed to bypass the standard taker fee. |
taker_fee_params.community_pool_denom_whitelist | Denoms sent directly to the community pool without an intermediary swap. |
taker_fee_params.daily_staking_rewards_smoothing_factor | Number of daily epochs over which staking-reward distributions are smoothed. |
authorized_quote_denoms | Deprecated. Quote-denom restrictions for concentrated pool creation were removed. |
Queries
The public query services are defined in the v1beta1 query proto and the v2 spot-price query proto.
| Category | Queries |
|---|---|
| Configuration | Params |
| Swap estimation | EstimateSwapExactAmountIn, EstimateSwapExactAmountInWithPrimitiveTypes, EstimateSinglePoolSwapExactAmountIn, EstimateSwapExactAmountOut, EstimateSwapExactAmountOutWithPrimitiveTypes, EstimateSinglePoolSwapExactAmountOut, EstimateTradeBasedOnPriceImpact |
| Pools and liquidity | NumPools, Pool, AllPools, ListPoolsByDenom, TotalPoolLiquidity, TotalLiquidity, TotalVolumeForPool |
| Prices and fees | SpotPrice, SpotPriceV2, TradingPairTakerFee |
| Taker-fee sharing | AllTakerFeeShareAgreements, TakerFeeShareAgreementFromDenom, TakerFeeShareDenomsToAccruedValue, AllTakerFeeShareAccumulators |
| Alloyed pools | RegisteredAlloyedPoolFromDenom, RegisteredAlloyedPoolFromPoolId, AllRegisteredAlloyedPools |