diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a61da4f3b..beb41cdb2a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [#6758](https://github.com/osmosis-labs/osmosis/pull/6758) Add codec for MsgUndelegateFromRebalancedValidatorSet * [#6836](https://github.com/osmosis-labs/osmosis/pull/6836) Add DenomsMetadata to stargate whitelist and fixs the DenomMetadata response type * [#6814](https://github.com/osmosis-labs/osmosis/pull/6814) Add EstimateTradeBasedOnPriceImpact to stargate whitelist +* [#6859](https://github.com/osmosis-labs/osmosis/pull/6859) Add hooks to core CL operations (position creation/withdrawal and swaps) ### Misc Improvements diff --git a/x/concentrated-liquidity/lp.go b/x/concentrated-liquidity/lp.go index 42525f3e823..d4546d5e046 100644 --- a/x/concentrated-liquidity/lp.go +++ b/x/concentrated-liquidity/lp.go @@ -43,6 +43,9 @@ type CreatePositionData struct { // - the liquidity delta is zero // - the amount0 or amount1 returned from the position update is less than the given minimums // - the pool or user does not have enough tokens to satisfy the requested amount +// +// BeforeCreatePosition hook is triggered after validation logic but before any state changes are made. +// AfterCreatePosition hook is triggered after state changes are complete if no errors have occurred. func (k Keeper) CreatePosition(ctx sdk.Context, poolId uint64, owner sdk.AccAddress, tokensProvided sdk.Coins, amount0Min, amount1Min osmomath.Int, lowerTick, upperTick int64) (CreatePositionData, error) { // Use the current blockTime as the position's join time. joinTime := ctx.BlockTime() @@ -89,8 +92,6 @@ func (k Keeper) CreatePosition(ctx sdk.Context, poolId uint64, owner sdk.AccAddr return CreatePositionData{}, err } - positionId := k.getNextPositionIdAndIncrement(ctx) - // If this is the first position created in this pool, ensure that the position includes both asset0 and asset1 // in order to assign an initial spot price. hasPositions, err := k.HasAnyPositionForPool(ctx, poolId) @@ -98,6 +99,15 @@ func (k Keeper) CreatePosition(ctx sdk.Context, poolId uint64, owner sdk.AccAddr return CreatePositionData{}, err } + // Trigger before hook for CreatePosition prior to mutating state. + // If no contract is set, this will be a no-op. + err = k.BeforeCreatePosition(ctx, poolId, owner, tokensProvided, amount0Min, amount1Min, lowerTick, upperTick) + if err != nil { + return CreatePositionData{}, err + } + + positionId := k.getNextPositionIdAndIncrement(ctx) + if !hasPositions { err := k.initializeInitialPositionForPool(ctx, pool, amount0Desired, amount1Desired) if err != nil { @@ -178,6 +188,13 @@ func (k Keeper) CreatePosition(ctx sdk.Context, poolId uint64, owner sdk.AccAddr } k.RecordTotalLiquidityIncrease(ctx, tokensAdded) + // Trigger after hook for CreatePosition. + // If no contract is set, this will be a no-op. + err = k.AfterCreatePosition(ctx, poolId, owner, tokensProvided, amount0Min, amount1Min, lowerTick, upperTick) + if err != nil { + return CreatePositionData{}, err + } + return CreatePositionData{ ID: positionId, Amount0: updateData.Amount0, @@ -203,6 +220,9 @@ func (k Keeper) CreatePosition(ctx sdk.Context, poolId uint64, owner sdk.AccAddr // - if the position's underlying lock is not mature // - if tick ranges are invalid // - if attempts to withdraw an amount higher than originally provided in createPosition for a given range. +// +// BeforeWithdrawPosition hook is triggered after validation logic but before any state changes are made. +// AfterWithdrawPosition hook is triggered after state changes are complete if no errors have occurred. func (k Keeper) WithdrawPosition(ctx sdk.Context, owner sdk.AccAddress, positionId uint64, requestedLiquidityAmountToWithdraw osmomath.Dec) (amtDenom0, amtDenom1 osmomath.Int, err error) { position, err := k.GetPosition(ctx, positionId) if err != nil { @@ -243,6 +263,13 @@ func (k Keeper) WithdrawPosition(ctx sdk.Context, owner sdk.AccAddress, position return osmomath.Int{}, osmomath.Int{}, types.InsufficientLiquidityError{Actual: requestedLiquidityAmountToWithdraw, Available: position.Liquidity} } + // Trigger before hook for WithdrawPosition prior to mutating state. + // If no contract is set, this will be a no-op. + err = k.BeforeWithdrawPosition(ctx, position.PoolId, owner, positionId, requestedLiquidityAmountToWithdraw) + if err != nil { + return osmomath.Int{}, osmomath.Int{}, err + } + _, _, err = k.collectIncentives(ctx, owner, positionId) if err != nil { return osmomath.Int{}, osmomath.Int{}, err @@ -331,6 +358,13 @@ func (k Keeper) WithdrawPosition(ctx sdk.Context, owner sdk.AccAddress, position } event.emit(ctx) + // Trigger after hook for WithdrawPosition. + // If no contract is set, this will be a no-op. + err = k.AfterWithdrawPosition(ctx, position.PoolId, owner, positionId, requestedLiquidityAmountToWithdraw) + if err != nil { + return osmomath.Int{}, osmomath.Int{}, err + } + return updateData.Amount0.Neg(), updateData.Amount1.Neg(), nil } diff --git a/x/concentrated-liquidity/msg_server.go b/x/concentrated-liquidity/msg_server.go index 5d9e7142e36..667a21834d7 100644 --- a/x/concentrated-liquidity/msg_server.go +++ b/x/concentrated-liquidity/msg_server.go @@ -45,7 +45,6 @@ func (server msgServer) CreateConcentratedPool(goCtx context.Context, msg *clmod return &clmodel.MsgCreateConcentratedPoolResponse{PoolID: poolId}, nil } -// TODO: tests, including events func (server msgServer) CreatePosition(goCtx context.Context, msg *types.MsgCreatePosition) (*types.MsgCreatePositionResponse, error) { ctx := sdk.UnwrapSDKContext(goCtx) diff --git a/x/concentrated-liquidity/pool_hooks.go b/x/concentrated-liquidity/pool_hooks.go index c73d31e9795..c814c072be8 100644 --- a/x/concentrated-liquidity/pool_hooks.go +++ b/x/concentrated-liquidity/pool_hooks.go @@ -2,7 +2,6 @@ package concentrated_liquidity import ( "encoding/json" - "fmt" "github.com/cosmos/cosmos-sdk/store/prefix" sdk "github.com/cosmos/cosmos-sdk/types" @@ -12,16 +11,6 @@ import ( types "github.com/osmosis-labs/osmosis/v20/x/concentrated-liquidity/types" ) -// Helper function to generate before action prefix -func beforeActionPrefix(action string) string { - return fmt.Sprintf("before%s", action) -} - -// Helper function to generate after action prefix -func afterActionPrefix(action string) string { - return fmt.Sprintf("after%s", action) -} - // --- Pool Hooks --- // BeforeCreatePosition is a hook that is called before a position is created. @@ -32,7 +21,7 @@ func (k Keeper) BeforeCreatePosition(ctx sdk.Context, poolId uint64, owner sdk.A if err != nil { return err } - return k.callPoolActionListener(ctx, msgBz, poolId, beforeActionPrefix(types.CreatePositionPrefix)) + return k.callPoolActionListener(ctx, msgBz, poolId, types.BeforeActionPrefix(types.CreatePositionPrefix)) } // AfterCreatePosition is a hook that is called after a position is created. @@ -43,29 +32,7 @@ func (k Keeper) AfterCreatePosition(ctx sdk.Context, poolId uint64, owner sdk.Ac if err != nil { return err } - return k.callPoolActionListener(ctx, msgBz, poolId, afterActionPrefix(types.CreatePositionPrefix)) -} - -// BeforeAddToPosition is a hook that is called before liquidity is added to a position. -func (k Keeper) BeforeAddToPosition(ctx sdk.Context, poolId uint64, owner sdk.AccAddress, positionId uint64, amount0Added osmomath.Int, amount1Added osmomath.Int, amount0Min osmomath.Int, amount1Min osmomath.Int) error { - // Build and marshal the message to be passed to the contract - msg := types.BeforeAddToPositionMsg{PoolId: poolId, Owner: owner, PositionId: positionId, Amount0Added: amount0Added, Amount1Added: amount1Added, Amount0Min: amount0Min, Amount1Min: amount1Min} - msgBz, err := json.Marshal(types.BeforeAddToPositionSudoMsg{BeforeAddToPosition: msg}) - if err != nil { - return err - } - return k.callPoolActionListener(ctx, msgBz, poolId, beforeActionPrefix(types.AddToPositionPrefix)) -} - -// AfterAddToPosition is a hook that is called after liquidity is added to a position. -func (k Keeper) AfterAddToPosition(ctx sdk.Context, poolId uint64, owner sdk.AccAddress, positionId uint64, amount0Added osmomath.Int, amount1Added osmomath.Int, amount0Min osmomath.Int, amount1Min osmomath.Int) error { - // Build and marshal the message to be passed to the contract - msg := types.AfterAddToPositionMsg{PoolId: poolId, Owner: owner, PositionId: positionId, Amount0Added: amount0Added, Amount1Added: amount1Added, Amount0Min: amount0Min, Amount1Min: amount1Min} - msgBz, err := json.Marshal(types.AfterAddToPositionSudoMsg{AfterAddToPosition: msg}) - if err != nil { - return err - } - return k.callPoolActionListener(ctx, msgBz, poolId, afterActionPrefix(types.AddToPositionPrefix)) + return k.callPoolActionListener(ctx, msgBz, poolId, types.AfterActionPrefix(types.CreatePositionPrefix)) } // BeforeWithdrawPosition is a hook that is called before liquidity is withdrawn from a position. @@ -76,7 +43,7 @@ func (k Keeper) BeforeWithdrawPosition(ctx sdk.Context, poolId uint64, owner sdk if err != nil { return err } - return k.callPoolActionListener(ctx, msgBz, poolId, beforeActionPrefix(types.WithdrawPositionPrefix)) + return k.callPoolActionListener(ctx, msgBz, poolId, types.BeforeActionPrefix(types.WithdrawPositionPrefix)) } // AfterWithdrawPosition is a hook that is called after liquidity is withdrawn from a position. @@ -87,7 +54,7 @@ func (k Keeper) AfterWithdrawPosition(ctx sdk.Context, poolId uint64, owner sdk. if err != nil { return err } - return k.callPoolActionListener(ctx, msgBz, poolId, afterActionPrefix(types.WithdrawPositionPrefix)) + return k.callPoolActionListener(ctx, msgBz, poolId, types.AfterActionPrefix(types.WithdrawPositionPrefix)) } // BeforeSwapExactAmountIn is a hook that is called before a swap is executed (exact amount in). @@ -98,7 +65,7 @@ func (k Keeper) BeforeSwapExactAmountIn(ctx sdk.Context, poolId uint64, sender s if err != nil { return err } - return k.callPoolActionListener(ctx, msgBz, poolId, beforeActionPrefix(types.SwapExactAmountInPrefix)) + return k.callPoolActionListener(ctx, msgBz, poolId, types.BeforeActionPrefix(types.SwapExactAmountInPrefix)) } // AfterSwapExactAmountIn is a hook that is called after a swap is executed (exact amount in). @@ -109,7 +76,7 @@ func (k Keeper) AfterSwapExactAmountIn(ctx sdk.Context, poolId uint64, sender sd if err != nil { return err } - return k.callPoolActionListener(ctx, msgBz, poolId, afterActionPrefix(types.SwapExactAmountInPrefix)) + return k.callPoolActionListener(ctx, msgBz, poolId, types.AfterActionPrefix(types.SwapExactAmountInPrefix)) } // BeforeSwapExactAmountOut is a hook that is called before a swap is executed (exact amount out). @@ -120,7 +87,7 @@ func (k Keeper) BeforeSwapExactAmountOut(ctx sdk.Context, poolId uint64, sender if err != nil { return err } - return k.callPoolActionListener(ctx, msgBz, poolId, beforeActionPrefix(types.SwapExactAmountOutPrefix)) + return k.callPoolActionListener(ctx, msgBz, poolId, types.BeforeActionPrefix(types.SwapExactAmountOutPrefix)) } // AfterSwapExactAmountOut is a hook that is called after a swap is executed (exact amount out). @@ -131,7 +98,7 @@ func (k Keeper) AfterSwapExactAmountOut(ctx sdk.Context, poolId uint64, sender s if err != nil { return err } - return k.callPoolActionListener(ctx, msgBz, poolId, afterActionPrefix(types.SwapExactAmountOutPrefix)) + return k.callPoolActionListener(ctx, msgBz, poolId, types.AfterActionPrefix(types.SwapExactAmountOutPrefix)) } // callPoolActionListener processes and dispatches the passed in message to the contract corresponding to the hook @@ -210,6 +177,11 @@ func (k Keeper) getPoolHookContract(ctx sdk.Context, poolId uint64, actionPrefix func (k Keeper) setPoolHookContract(ctx sdk.Context, poolID uint64, actionPrefix string, cosmwasmAddress string) error { store := k.getPoolHookPrefixStore(ctx, poolID) + validActionPrefixes := types.GetAllActionPrefixes() + if !osmoutils.Contains(validActionPrefixes, actionPrefix) { + return types.InvalidActionPrefixError{ActionPrefix: actionPrefix, ValidActions: validActionPrefixes} + } + // If cosmwasm address is nil, treat this as a delete operation for the stored address. if cosmwasmAddress == "" { deletePoolHookContract(store, actionPrefix) diff --git a/x/concentrated-liquidity/pool_hooks_test.go b/x/concentrated-liquidity/pool_hooks_test.go index 05fa99cdcd5..b49fcaec704 100644 --- a/x/concentrated-liquidity/pool_hooks_test.go +++ b/x/concentrated-liquidity/pool_hooks_test.go @@ -7,13 +7,14 @@ import ( wasmkeeper "github.com/CosmWasm/wasmd/x/wasm/keeper" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/osmosis-labs/osmosis/osmoutils" "github.com/osmosis-labs/osmosis/v20/x/concentrated-liquidity/types" ) var ( validCosmwasmAddress = "osmo14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sq2r9g9" invalidCosmwasmAddress = "osmo1{}{}4hj2tfpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sq2r9g9" - validActionPrefix = "beforeSwap" + validActionPrefix = "beforeSwapExactAmountIn" counterContractPath = "./testcontracts/compiled-wasm/counter.wasm" ) @@ -55,6 +56,13 @@ func (s *KeeperTestSuite) TestSetAndGetPoolHookContract() { actionPrefix: validActionPrefix, poolId: validPoolId, + expectErrOnSet: true, + }, + "error: invalid hook action": { + cosmwasmAddress: invalidCosmwasmAddress, + actionPrefix: "invalidActionPrefix", + poolId: validPoolId, + expectErrOnSet: true, }, } @@ -152,7 +160,7 @@ func (s *KeeperTestSuite) TestCallPoolActionListener() { // --- Setup --- // Upload and instantiate wasm code - cosmwasmAddressBech32 := s.uploadAndInstantiateContract(tc.wasmFile) + _, cosmwasmAddressBech32 := s.uploadAndInstantiateContract(tc.wasmFile) // Set pool hook contract to the newly instantiated contract err := s.Clk.SetPoolHookContract(s.Ctx, validPoolId, validActionPrefix, cosmwasmAddressBech32) @@ -178,9 +186,131 @@ func (s *KeeperTestSuite) TestCallPoolActionListener() { } } +// Pool hook tests +// General testing strategy: +// 1. Build a pre-defined contract that defines the following behavior for all hooks: +// if triggered, transfer 1 token with denom corresponding to the action prefix +// e.g. if action prefix is "beforeSwap", transfer 1 token with denom "beforeSwap" +// 2. Set this contract for all hooks defined by the test case (each case should have a list +// of action prefixes it wants to "activate") +// 3. Run a series of actions that would trigger all the hooks (create, withdraw from, swap against a position), +// and ensure that the correct denoms are in the account balance after each action/at the end. +// +// NOTE: we assume that set contracts have valid implementations for all hooks and that this is validated +// at the contract setting stage at a higher level of abstraction. Thus, this class of errors is not covered +// by these tests. +func (s *KeeperTestSuite) TestPoolHooks() { + hookContractFilePath := "./testcontracts/compiled-wasm/hooks.wasm" + + allBeforeHooks := []string{ + before(types.CreatePositionPrefix), + before(types.WithdrawPositionPrefix), + before(types.SwapExactAmountInPrefix), + before(types.SwapExactAmountOutPrefix), + } + + allAfterHooks := []string{ + after(types.CreatePositionPrefix), + after(types.WithdrawPositionPrefix), + after(types.SwapExactAmountInPrefix), + after(types.SwapExactAmountOutPrefix), + } + + allHooks := append(allBeforeHooks, allAfterHooks...) + + testCases := map[string]struct { + actionPrefixes []string + }{ + "single hook: before create position": { + actionPrefixes: []string{before(types.CreatePositionPrefix)}, + }, + "all before hooks": { + actionPrefixes: allBeforeHooks, + }, + "all after hooks": { + actionPrefixes: allAfterHooks, + }, + "all hooks": { + actionPrefixes: allHooks, + }, + } + + for name, tc := range testCases { + s.Run(name, func() { + s.SetupTest() + clPool := s.PrepareConcentratedPool() + + // Upload and instantiate wasm code + rawCosmwasmAddress, cosmwasmAddressBech32 := s.uploadAndInstantiateContract(hookContractFilePath) + + // Fund the contract with tokens for all action prefixes using a helper + for _, actionPrefix := range tc.actionPrefixes { + s.FundAcc(rawCosmwasmAddress, sdk.NewCoins(sdk.NewCoin(actionPrefix, sdk.NewInt(10)))) + } + + // Set the contract for all hooks as defined by tc.actionPrefixes + for _, actionPrefix := range tc.actionPrefixes { + // We use the bech32 address here since the set function expects it for security reasons + err := s.Clk.SetPoolHookContract(s.Ctx, validPoolId, actionPrefix, cosmwasmAddressBech32) + s.Require().NoError(err) + } + + // --- Execute a series of actions that trigger all supported hooks if set --- + + // Create position + _, positionId := s.SetupPosition(clPool.GetId(), s.TestAccs[0], DefaultCoins, types.MinInitializedTick, types.MaxTick, true) + + // Withdraw from position + _, _, err := s.Clk.WithdrawPosition(s.Ctx, s.TestAccs[0], positionId, sdk.NewDec(100)) + s.Require().NoError(err) + + // Execute swap (SwapExactAmountIn) + s.FundAcc(rawCosmwasmAddress, sdk.NewCoins(sdk.NewCoin(types.SwapExactAmountInPrefix, sdk.NewInt(10)))) + _, err = s.Clk.SwapExactAmountIn(s.Ctx, s.TestAccs[0], clPool, sdk.NewCoin(ETH, sdk.NewInt(1)), USDC, sdk.ZeroInt(), DefaultZeroSpreadFactor) + s.Require().NoError(err) + + // Execute swap (SwapExactAmountOut) + s.FundAcc(rawCosmwasmAddress, sdk.NewCoins(sdk.NewCoin(types.SwapExactAmountOutPrefix, sdk.NewInt(10)))) + _, err = s.Clk.SwapExactAmountOut(s.Ctx, s.TestAccs[0], clPool, ETH, sdk.NewInt(100), sdk.NewCoin(USDC, sdk.NewInt(10)), DefaultZeroSpreadFactor) + s.Require().NoError(err) + + // Check that each set hook was successfully triggered. + // These assertions lean on the test construction defined in the comments for these tests. + // In short, each hook trigger is expected to transfer 1 token with denom corresponding to the + // action that triggered it. + expectedTriggers := sdk.NewCoins() + for _, actionPrefix := range tc.actionPrefixes { + expectedTriggers = expectedTriggers.Add(sdk.NewCoin(actionPrefix, sdk.NewInt(1))) + } + + // Ensure that correct hooks were triggered + balances := s.App.BankKeeper.GetAllBalances(s.Ctx, s.TestAccs[0]) + s.Require().True(expectedTriggers.DenomsSubsetOf(balances), "expected balance to include: %s, actual balances: %s", expectedTriggers, balances) + + // Derive actions that should not have been triggered + notTriggeredActions := osmoutils.Filter[string](func(s string) bool { return osmoutils.Contains(tc.actionPrefixes, s) }, allHooks) + + // Ensure that hooks that weren't set weren't triggered + for _, action := range notTriggeredActions { + s.Require().False(osmoutils.Contains(balances, sdk.NewCoin(action, sdk.NewInt(1))), "expected balance to not include: %s, actual balances: %s", action, balances) + } + }) + } +} + +// Adds "before" prefix to action (helper for test readability) +func before(action string) string { + return types.BeforeActionPrefix(action) +} + +// Adds "after" prefix to action (helper for test readability) +func after(action string) string { + return types.AfterActionPrefix(action) +} + // uploadAndInstantiateContract is a helper function to upload and instantiate a contract from a given file path. // It calls an empty Instantiate message on the created contract and returns the bech32 address after instantiation. -func (s *KeeperTestSuite) uploadAndInstantiateContract(filePath string) (cosmwasmAddressBech32 string) { +func (s *KeeperTestSuite) uploadAndInstantiateContract(filePath string) (rawCWAddr sdk.AccAddress, bech32CWAddr string) { // We use a gov permissioned contract keeper to avoid having to manually set permissions contractKeeper := wasmkeeper.NewGovPermissionKeeper(s.App.WasmKeeper) @@ -189,10 +319,10 @@ func (s *KeeperTestSuite) uploadAndInstantiateContract(filePath string) (cosmwas s.Require().NoError(err) codeID, _, err := contractKeeper.Create(s.Ctx, s.TestAccs[0], wasmCode, nil) s.Require().NoError(err) - cosmwasmAddress, _, err := contractKeeper.Instantiate(s.Ctx, codeID, s.TestAccs[0], s.TestAccs[0], []byte("{}"), "", sdk.NewCoins()) + rawCWAddr, _, err = contractKeeper.Instantiate(s.Ctx, codeID, s.TestAccs[0], s.TestAccs[0], []byte("{}"), "", sdk.NewCoins()) s.Require().NoError(err) - cosmwasmAddressBech32, err = sdk.Bech32ifyAddressBytes("osmo", cosmwasmAddress) + bech32CWAddr, err = sdk.Bech32ifyAddressBytes("osmo", rawCWAddr) s.Require().NoError(err) - return cosmwasmAddressBech32 + return rawCWAddr, bech32CWAddr } diff --git a/x/concentrated-liquidity/swaps.go b/x/concentrated-liquidity/swaps.go index b50b7420b9d..4842f5d2721 100644 --- a/x/concentrated-liquidity/swaps.go +++ b/x/concentrated-liquidity/swaps.go @@ -142,6 +142,13 @@ func (k Keeper) SwapExactAmountIn( return osmomath.Int{}, err } + // Trigger before hook for SwapExactAmountIn prior to mutating state. + // If no contract is set, this will be a no-op. + err = k.BeforeSwapExactAmountIn(ctx, pool.GetId(), sender, tokenIn, tokenOutDenom, tokenOutMinAmount, spreadFactor) + if err != nil { + return osmomath.Int{}, err + } + // Determine if we are swapping asset0 for asset1 or vice versa zeroForOne := getZeroForOne(tokenIn.Denom, pool.GetToken0()) @@ -161,6 +168,13 @@ func (k Keeper) SwapExactAmountIn( k.RecordTotalLiquidityIncrease(ctx, sdk.NewCoins(tokenIn)) k.RecordTotalLiquidityDecrease(ctx, sdk.NewCoins(tokenOut)) + // Trigger after hook for SwapExactAmountIn after mutating state. + // If no contract is set, this will be a no-op. + err = k.AfterSwapExactAmountIn(ctx, pool.GetId(), sender, tokenIn, tokenOutDenom, tokenOutMinAmount, spreadFactor) + if err != nil { + return osmomath.Int{}, err + } + return tokenOutAmount, nil } @@ -184,6 +198,13 @@ func (k Keeper) SwapExactAmountOut( return osmomath.Int{}, err } + // Trigger before hook for SwapExactAmountOut prior to mutating state. + // If no contract is set, this will be a no-op. + err = k.BeforeSwapExactAmountOut(ctx, pool.GetId(), sender, tokenInDenom, tokenInMaxAmount, tokenOut, spreadFactor) + if err != nil { + return osmomath.Int{}, err + } + zeroForOne := getZeroForOne(tokenInDenom, pool.GetToken0()) // change priceLimit based on which direction we are swapping @@ -203,6 +224,13 @@ func (k Keeper) SwapExactAmountOut( k.RecordTotalLiquidityIncrease(ctx, sdk.NewCoins(tokenIn)) k.RecordTotalLiquidityDecrease(ctx, sdk.NewCoins(tokenOut)) + // Trigger after hook for SwapExactAmountOut after mutating state. + // If no contract is set, this will be a no-op. + err = k.AfterSwapExactAmountOut(ctx, pool.GetId(), sender, tokenInDenom, tokenInMaxAmount, tokenOut, spreadFactor) + if err != nil { + return osmomath.Int{}, err + } + return tokenInAmount, nil } diff --git a/x/concentrated-liquidity/testcontracts/compiled-wasm/hooks.wasm b/x/concentrated-liquidity/testcontracts/compiled-wasm/hooks.wasm new file mode 100755 index 00000000000..88d013a9bdf Binary files /dev/null and b/x/concentrated-liquidity/testcontracts/compiled-wasm/hooks.wasm differ diff --git a/x/concentrated-liquidity/types/errors.go b/x/concentrated-liquidity/types/errors.go index 1f7ae61eb23..fa2c431f41a 100644 --- a/x/concentrated-liquidity/types/errors.go +++ b/x/concentrated-liquidity/types/errors.go @@ -921,3 +921,12 @@ type ContractHookOutOfGasError struct { func (e ContractHookOutOfGasError) Error() string { return fmt.Sprintf("A single contract call cannot exceed %d gas in a CL hook call.", e.GasLimit) } + +type InvalidActionPrefixError struct { + ActionPrefix string + ValidActions []string +} + +func (e InvalidActionPrefixError) Error() string { + return fmt.Sprintf("invalid action prefix (%s). Valid actions: %s", e.ActionPrefix, e.ValidActions) +} diff --git a/x/concentrated-liquidity/types/pool_hooks.go b/x/concentrated-liquidity/types/pool_hooks.go index 9b3f045a160..305767ab8d9 100644 --- a/x/concentrated-liquidity/types/pool_hooks.go +++ b/x/concentrated-liquidity/types/pool_hooks.go @@ -1,6 +1,8 @@ package types import ( + fmt "fmt" + sdk "github.com/cosmos/cosmos-sdk/types" wasmvmtypes "github.com/CosmWasm/wasmvm/types" @@ -10,13 +12,37 @@ import ( // Action prefixes for pool actions const ( - CreatePositionPrefix = "createPosition" - AddToPositionPrefix = "addToPosition" - WithdrawPositionPrefix = "withdrawPosition" - SwapExactAmountInPrefix = "swapExactAmountIn" - SwapExactAmountOutPrefix = "swapExactAmountOut" + CreatePositionPrefix = "CreatePosition" + WithdrawPositionPrefix = "WithdrawPosition" + SwapExactAmountInPrefix = "SwapExactAmountIn" + SwapExactAmountOutPrefix = "SwapExactAmountOut" ) +// Helper function to generate before action prefix +func BeforeActionPrefix(action string) string { + return fmt.Sprintf("before%s", action) +} + +// Helper function to generate after action prefix +func AfterActionPrefix(action string) string { + return fmt.Sprintf("after%s", action) +} + +// GetAllActionPrefixes returns all the action prefixes corresponding to valid hooks +func GetAllActionPrefixes() []string { + result := []string{} + for _, prefix := range []string{ + CreatePositionPrefix, + WithdrawPositionPrefix, + SwapExactAmountInPrefix, + SwapExactAmountOutPrefix, + } { + result = append(result, BeforeActionPrefix(prefix), AfterActionPrefix(prefix)) + } + + return result +} + // --- Sudo Message Wrappers --- type BeforeCreatePositionSudoMsg struct { @@ -27,14 +53,6 @@ type AfterCreatePositionSudoMsg struct { AfterCreatePosition AfterCreatePositionMsg `json:"after_create_position"` } -type BeforeAddToPositionSudoMsg struct { - BeforeAddToPosition BeforeAddToPositionMsg `json:"before_add_to_position"` -} - -type AfterAddToPositionSudoMsg struct { - AfterAddToPosition AfterAddToPositionMsg `json:"after_add_to_position"` -} - type BeforeWithdrawPositionSudoMsg struct { BeforeWithdrawPosition BeforeWithdrawPositionMsg `json:"before_withdraw_position"` } @@ -81,26 +99,6 @@ type AfterCreatePositionMsg struct { UpperTick int64 `json:"upper_tick"` } -type BeforeAddToPositionMsg struct { - PoolId uint64 `json:"pool_id"` - Owner sdk.AccAddress `json:"owner"` - PositionId uint64 `json:"position_id"` - Amount0Added osmomath.Int `json:"amount_0_added"` - Amount1Added osmomath.Int `json:"amount_1_added"` - Amount0Min osmomath.Int `json:"amount_0_min"` - Amount1Min osmomath.Int `json:"amount_1_min"` -} - -type AfterAddToPositionMsg struct { - PoolId uint64 `json:"pool_id"` - Owner sdk.AccAddress `json:"owner"` - PositionId uint64 `json:"position_id"` - Amount0Added osmomath.Int `json:"amount_0_added"` - Amount1Added osmomath.Int `json:"amount_1_added"` - Amount0Min osmomath.Int `json:"amount_0_min"` - Amount1Min osmomath.Int `json:"amount_1_min"` -} - type BeforeWithdrawPositionMsg struct { PoolId uint64 `json:"pool_id"` Owner sdk.AccAddress `json:"owner"`