diff --git a/Cargo.lock b/Cargo.lock
index 46d177b..703c280 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -401,6 +401,21 @@ dependencies = [
  "cosmwasm-std",
 ]
 
+[[package]]
+name = "cw-controllers"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57de8d3761e46be863e3ac1eba8c8a976362a48c6abf240df1e26c3e421ee9e8"
+dependencies = [
+ "cosmwasm-schema",
+ "cosmwasm-std",
+ "cw-storage-plus",
+ "cw-utils",
+ "schemars",
+ "serde",
+ "thiserror",
+]
+
 [[package]]
 name = "cw-ibc-lite-derive"
 version = "0.1.0"
@@ -445,6 +460,25 @@ dependencies = [
  "thiserror",
 ]
 
+[[package]]
+name = "cw-ibc-lite-ics20-transfer"
+version = "0.1.0"
+dependencies = [
+ "cosmwasm-schema",
+ "cosmwasm-std",
+ "cw-ibc-lite-ics02-client",
+ "cw-ibc-lite-shared",
+ "cw-storage-plus",
+ "cw2",
+ "cw20",
+ "cw20-ics20",
+ "ibc-client-cw",
+ "ibc-core-host",
+ "schemars",
+ "serde",
+ "thiserror",
+]
+
 [[package]]
 name = "cw-ibc-lite-ics26-router"
 version = "0.1.0"
@@ -547,6 +581,38 @@ dependencies = [
  "thiserror",
 ]
 
+[[package]]
+name = "cw20"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "526e39bb20534e25a1cd0386727f0038f4da294e5e535729ba3ef54055246abd"
+dependencies = [
+ "cosmwasm-schema",
+ "cosmwasm-std",
+ "cw-utils",
+ "schemars",
+ "serde",
+]
+
+[[package]]
+name = "cw20-ics20"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76221201da08fed611c857ea3aa21c031a4a7dc771a8b1750559ca987335dc02"
+dependencies = [
+ "cosmwasm-schema",
+ "cosmwasm-std",
+ "cw-controllers",
+ "cw-storage-plus",
+ "cw-utils",
+ "cw2",
+ "cw20",
+ "schemars",
+ "semver",
+ "serde",
+ "thiserror",
+]
+
 [[package]]
 name = "der"
 version = "0.7.9"
diff --git a/Cargo.toml b/Cargo.toml
index c6841c1..ec45b25 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,6 +36,8 @@ cosmwasm-std = { version = "1.5.5", features = [
 ] }
 cw-storage-plus = "1.2.0"
 cw2 = "1.1.2"
+cw20 = "1.1.2"
+cw20-ics20 = "1.1.2"
 schemars = "0.8.16"
 serde = { version = "1.0.197", default-features = false, features = ["derive"] }
 thiserror = { version = "1.0.58" }
diff --git a/contracts/ics07-tendermint/src/contract.rs b/contracts/ics07-tendermint/src/contract.rs
index 987daf5..125a620 100644
--- a/contracts/ics07-tendermint/src/contract.rs
+++ b/contracts/ics07-tendermint/src/contract.rs
@@ -75,8 +75,9 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> Result<Binary, ContractErro
             query::check_for_misbehaviour(deps, env, msg.try_into()?)
         }
         QueryMsg::VerifyMembership(_) | QueryMsg::VerifyNonMembership(_) => {
-            query::execute_query(deps, env, msg.try_into()?)
+            query::sudo_query(deps, env, msg.try_into()?)
         }
+        QueryMsg::Ownership {} => query::ownership(deps),
     }
 }
 
@@ -145,7 +146,7 @@ mod query {
     }
 
     #[allow(clippy::needless_pass_by_value, clippy::module_name_repetitions)]
-    pub fn execute_query(
+    pub fn sudo_query(
         deps: Deps,
         env: Env,
         msg: ibc_client_cw::types::SudoMsg,
@@ -156,4 +157,10 @@ mod query {
 
         ctx.sudo(msg).map_err(ContractError::from)
     }
+
+    pub fn ownership(deps: Deps) -> Result<Binary, ContractError> {
+        Ok(cosmwasm_std::to_json_binary(&cw_ownable::get_ownership(
+            deps.storage,
+        )?)?)
+    }
 }
diff --git a/contracts/ics20-transfer/.gitignore b/contracts/ics20-transfer/.gitignore
new file mode 100644
index 0000000..9095dea
--- /dev/null
+++ b/contracts/ics20-transfer/.gitignore
@@ -0,0 +1,16 @@
+# Build results
+/target
+/schema
+
+# Cargo+Git helper file (https://github.com/rust-lang/cargo/blob/0.44.1/src/cargo/sources/git/utils.rs#L320-L327)
+.cargo-ok
+
+# Text file backups
+**/*.rs.bk
+
+# macOS
+.DS_Store
+
+# IDEs
+*.iml
+.idea
diff --git a/contracts/ics20-transfer/Cargo.toml b/contracts/ics20-transfer/Cargo.toml
new file mode 100644
index 0000000..b2a5fbe
--- /dev/null
+++ b/contracts/ics20-transfer/Cargo.toml
@@ -0,0 +1,32 @@
+[package]
+name = "cw-ibc-lite-ics20-transfer"
+description = "ICS-20 Transfer application for `cw-ibc-lite`"
+version = { workspace = true }
+authors = { workspace = true }
+edition = { workspace = true }
+repository = { workspace = true }
+license = { workspace = true }
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[features]
+# exclude export feature to disable all instantiate/execute/query exports
+default = ["export"]
+export = []
+
+[dependencies]
+cosmwasm-schema = { workspace = true }
+cosmwasm-std = { workspace = true }
+cw-storage-plus = { workspace = true }
+cw2 = { workspace = true }
+cw20-ics20 = { workspace = true }
+cw20 = { workspace = true }
+schemars = { workspace = true }
+serde = { workspace = true }
+thiserror = { workspace = true }
+ibc-core-host = { workspace = true }
+cw-ibc-lite-shared = { workspace = true }
+cw-ibc-lite-ics02-client = { workspace = true }
+ibc-client-cw = { workspace = true }
diff --git a/contracts/ics20-transfer/README.md b/contracts/ics20-transfer/README.md
new file mode 100644
index 0000000..52f7529
--- /dev/null
+++ b/contracts/ics20-transfer/README.md
@@ -0,0 +1,3 @@
+# CosmWasm IBC Lite Transfer App
+
+This is a transfer application for `cw-ibc-lite`. It is based on [cw20-ics20](https://github.com/CosmWasm/cw-plus/tree/main/contracts/cw20-ics20) contract. It currently only works with cw20 tokens.
diff --git a/contracts/ics20-transfer/src/bin/schema.rs b/contracts/ics20-transfer/src/bin/schema.rs
new file mode 100644
index 0000000..d80ae70
--- /dev/null
+++ b/contracts/ics20-transfer/src/bin/schema.rs
@@ -0,0 +1,11 @@
+use cosmwasm_schema::write_api;
+
+use cw_ibc_lite_ics20_transfer::types::msg::{ExecuteMsg, InstantiateMsg, QueryMsg};
+
+fn main() {
+    write_api! {
+        instantiate: InstantiateMsg,
+        execute: ExecuteMsg,
+        query: QueryMsg,
+    }
+}
diff --git a/contracts/ics20-transfer/src/contract.rs b/contracts/ics20-transfer/src/contract.rs
new file mode 100644
index 0000000..1e8692b
--- /dev/null
+++ b/contracts/ics20-transfer/src/contract.rs
@@ -0,0 +1,100 @@
+//! This module handles the execution logic of the contract.
+
+use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Response};
+
+use cw_ibc_lite_shared::types::error::ContractError;
+
+use crate::types::{
+    keys,
+    msg::{ExecuteMsg, InstantiateMsg, QueryMsg},
+};
+
+/// Instantiates a new contract.
+///
+/// # Errors
+/// Will return an error if the instantiation fails.
+#[allow(clippy::needless_pass_by_value)]
+#[cosmwasm_std::entry_point]
+pub fn instantiate(
+    deps: DepsMut,
+    _env: Env,
+    _info: MessageInfo,
+    _msg: InstantiateMsg,
+) -> Result<Response, ContractError> {
+    // NOTE: Contract admin is assumed to be the ics26-router contract.
+    cw2::set_contract_version(deps.storage, keys::CONTRACT_NAME, keys::CONTRACT_VERSION)?;
+
+    todo!()
+}
+
+/// Handles the execution of the contract by routing the messages to the respective handlers.
+///
+/// # Errors
+/// Will return an error if the handler returns an error.
+#[allow(clippy::needless_pass_by_value)]
+#[cosmwasm_std::entry_point]
+pub fn execute(
+    deps: DepsMut,
+    env: Env,
+    info: MessageInfo,
+    msg: ExecuteMsg,
+) -> Result<Response, ContractError> {
+    match msg {
+        ExecuteMsg::Receive(receive_msg) => execute::receive(deps, env, info, receive_msg),
+        ExecuteMsg::ReceiveIbcAppCallback(callback_msg) => {
+            execute::receive_ibc_callback(deps, env, info, callback_msg)
+        }
+    }
+}
+
+/// Handles the query messages by routing them to the respective handlers.
+///
+/// # Errors
+/// Will return an error if the handler returns an error.
+#[allow(clippy::needless_pass_by_value)]
+#[cosmwasm_std::entry_point]
+pub fn query(_deps: Deps, _env: Env, _msg: QueryMsg) -> Result<Binary, ContractError> {
+    todo!()
+}
+
+mod execute {
+    use cw_ibc_lite_shared::types::{apps::callbacks::IbcAppCallbackMsg, error::TransferError};
+
+    use crate::types::{msg::TransferMsg, state};
+
+    use super::{ContractError, DepsMut, Env, MessageInfo, Response};
+
+    #[allow(clippy::needless_pass_by_value)]
+    pub fn receive(
+        _deps: DepsMut,
+        _env: Env,
+        info: MessageInfo,
+        msg: cw20::Cw20ReceiveMsg,
+    ) -> Result<Response, ContractError> {
+        if !info.funds.is_empty() {
+            return Err(TransferError::UnexpectedNativeToken.into());
+        }
+
+        // NOTE: We use the sender contract address as the denom.
+        let _denom = info.sender.as_str();
+        let _transfer_msg: TransferMsg = cosmwasm_std::from_json(msg.msg)?;
+        todo!()
+    }
+
+    #[allow(clippy::needless_pass_by_value)]
+    pub fn receive_ibc_callback(
+        deps: DepsMut,
+        env: Env,
+        info: MessageInfo,
+        msg: IbcAppCallbackMsg,
+    ) -> Result<Response, ContractError> {
+        state::admin::assert_admin(&env, &deps.querier, &info.sender)?;
+
+        match msg {
+            IbcAppCallbackMsg::OnSendPacket { .. } => todo!(),
+            IbcAppCallbackMsg::OnRecvPacket { .. } => todo!(),
+            IbcAppCallbackMsg::OnAcknowledgementPacket { .. } => todo!(),
+            IbcAppCallbackMsg::OnTimeoutPacket { .. } => todo!(),
+        }
+    }
+}
diff --git a/contracts/ics20-transfer/src/lib.rs b/contracts/ics20-transfer/src/lib.rs
new file mode 100644
index 0000000..f260b20
--- /dev/null
+++ b/contracts/ics20-transfer/src/lib.rs
@@ -0,0 +1,7 @@
+#![doc = include_str!("../README.md")]
+#![deny(missing_docs)]
+#![deny(clippy::nursery, clippy::pedantic, warnings)]
+
+#[cfg(feature = "export")]
+pub mod contract;
+pub mod types;
diff --git a/contracts/ics20-transfer/src/types/events.rs b/contracts/ics20-transfer/src/types/events.rs
new file mode 100644
index 0000000..870c4d2
--- /dev/null
+++ b/contracts/ics20-transfer/src/types/events.rs
@@ -0,0 +1 @@
+//! `cw-ibc-lite-ics20-transfer` Event Keys
diff --git a/contracts/ics20-transfer/src/types/keys.rs b/contracts/ics20-transfer/src/types/keys.rs
new file mode 100644
index 0000000..4d6da32
--- /dev/null
+++ b/contracts/ics20-transfer/src/types/keys.rs
@@ -0,0 +1,12 @@
+//! # Keys
+//!
+//! Contains key constants definitions for the contract such as version info for migrations.
+
+/// `CONTRACT_NAME` is the name of the contract recorded with [`cw2`]
+pub const CONTRACT_NAME: &str = "crates.io:cw-ibc-lite-ics20-transfer";
+/// `CONTRACT_VERSION` is the version of the cargo package.
+/// This is also the version of the contract recorded in [`cw2`]
+pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+/// `ICS20_VERSION` is the version of the ICS20 module used in the contract.
+pub const ICS20_VERSION: &str = "ics20-1";
diff --git a/contracts/ics20-transfer/src/types/mod.rs b/contracts/ics20-transfer/src/types/mod.rs
new file mode 100644
index 0000000..9116a4a
--- /dev/null
+++ b/contracts/ics20-transfer/src/types/mod.rs
@@ -0,0 +1,7 @@
+//! This module contains the types used by the contract's execution and state logic.
+
+pub mod events;
+pub mod keys;
+#[allow(clippy::module_name_repetitions)]
+pub mod msg;
+pub mod state;
diff --git a/contracts/ics20-transfer/src/types/msg.rs b/contracts/ics20-transfer/src/types/msg.rs
new file mode 100644
index 0000000..eee50e5
--- /dev/null
+++ b/contracts/ics20-transfer/src/types/msg.rs
@@ -0,0 +1,42 @@
+//! # Messages
+//!
+//! This module defines the messages that this contract receives.
+
+use cosmwasm_schema::{cw_serde, QueryResponses};
+
+use cw_ibc_lite_shared::types::apps::helpers::ibc_lite_app_callback;
+
+/// The message to instantiate the contract.
+#[cw_serde]
+pub struct InstantiateMsg {}
+
+/// The execute messages supported by the contract.
+#[ibc_lite_app_callback]
+#[cw_serde]
+pub enum ExecuteMsg {
+    /// This accepts a properly-encoded ReceiveMsg from a cw20 contract
+    /// The wrapped message is expected to be [`TransferMsg`].
+    Receive(cw20::Cw20ReceiveMsg),
+}
+
+/// This is the message we accept via [`ExecuteMsg::Receive`].
+#[cw_serde]
+pub struct TransferMsg {
+    /// The local channel to send the packets on
+    pub source_channel: String,
+    /// The remote address to send to.
+    /// Don't use HumanAddress as this will likely have a different Bech32 prefix than we use
+    /// and cannot be validated locally
+    pub receiver: String,
+    /// How long the packet lives in seconds. If not specified, use default_timeout
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub timeout: Option<u64>,
+    /// An optional memo to add to the IBC transfer
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub memo: Option<String>,
+}
+
+/// The query messages supported by the contract.
+#[cw_serde]
+#[derive(QueryResponses)]
+pub enum QueryMsg {}
diff --git a/contracts/ics20-transfer/src/types/state.rs b/contracts/ics20-transfer/src/types/state.rs
new file mode 100644
index 0000000..c1a5e46
--- /dev/null
+++ b/contracts/ics20-transfer/src/types/state.rs
@@ -0,0 +1,30 @@
+//! This module defines the state storage of the Contract.
+
+/// A collection of methods to access the admin of the contract.
+pub mod admin {
+    use cosmwasm_std::{Addr, Env, QuerierWrapper};
+    use cw_ibc_lite_shared::types::error::ContractError;
+
+    /// Asserts that the given address is the admin of the contract.
+    ///
+    /// # Errors
+    /// Returns an error if the given address is not the admin of the contract or the contract
+    /// doesn't have an admin.
+    #[allow(clippy::module_name_repetitions)]
+    pub fn assert_admin(
+        env: &Env,
+        querier: &QuerierWrapper,
+        addr: &Addr,
+    ) -> Result<(), ContractError> {
+        let admin = querier
+            .query_wasm_contract_info(&env.contract.address)?
+            .admin
+            .ok_or(ContractError::Unauthorized)?;
+
+        if admin != addr.as_str() {
+            return Err(ContractError::Unauthorized);
+        }
+
+        Ok(())
+    }
+}
diff --git a/packages/derive/README.md b/packages/derive/README.md
index 8da03dc..6de59c3 100644
--- a/packages/derive/README.md
+++ b/packages/derive/README.md
@@ -11,7 +11,7 @@ message enum variant into their `ExecuteMsg` enum.
 
 ```rust
 use cosmwasm_schema::{cw_serde, QueryResponses};
-use cw_ibc_lite_shared::types::apps::callbacks::ibc_lite_callback;
+use cw_ibc_lite_shared::types::apps::helpers::ibc_lite_callback;
 
 #[cw_serde]
 pub struct InstantiateMsg {}
diff --git a/packages/derive/src/lib.rs b/packages/derive/src/lib.rs
index 42948bf..fa48f87 100644
--- a/packages/derive/src/lib.rs
+++ b/packages/derive/src/lib.rs
@@ -13,7 +13,7 @@ use syn::{parse_macro_input, AttributeArgs, DataEnum, DeriveInput};
 /// For example:
 ///
 /// ```
-/// use cw_ibc_lite_shared::types::apps::callbacks::ibc_lite_app_callback;
+/// use cw_ibc_lite_shared::types::apps::helpers::ibc_lite_app_callback;
 /// use cosmwasm_schema::cw_serde;
 ///
 /// #[ibc_lite_app_callback]
@@ -35,7 +35,7 @@ use syn::{parse_macro_input, AttributeArgs, DataEnum, DeriveInput};
 /// occurs before the addition of the field.
 ///
 /// ```compile_fail
-/// use cw_ibc_lite_shared::types::apps::callbacks::ibc_lite_app_callback;
+/// use cw_ibc_lite_shared::types::apps::helpers::ibc_lite_app_callback;
 /// use cosmwasm_schema::cw_serde;
 ///
 /// #[derive(Clone)]
diff --git a/packages/shared/src/types/apps/callbacks.rs b/packages/shared/src/types/apps/callbacks.rs
index 5d30981..5a0aeec 100644
--- a/packages/shared/src/types/apps/callbacks.rs
+++ b/packages/shared/src/types/apps/callbacks.rs
@@ -6,9 +6,6 @@
 use cosmwasm_schema::cw_serde;
 use cosmwasm_std::{Binary, StdResult};
 
-// Export the derive macro
-pub use cw_ibc_lite_derive::ibc_lite_app_callback;
-
 /// All IBC applications built with `cw-ibc-lite` must handle these callback messages.
 #[cw_serde]
 pub enum IbcAppCallbackMsg {
diff --git a/packages/shared/src/types/apps/helpers.rs b/packages/shared/src/types/apps/helpers.rs
index 47212d1..049f0ae 100644
--- a/packages/shared/src/types/apps/helpers.rs
+++ b/packages/shared/src/types/apps/helpers.rs
@@ -6,6 +6,9 @@ use super::callbacks::{self};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 
+// Export the derive macro
+pub use cw_ibc_lite_derive::ibc_lite_app_callback;
+
 use cosmwasm_std::{Addr, CosmosMsg, StdResult, WasmMsg};
 
 /// `IbcApplicationContract` is a wrapper around Addr that provides helpers
diff --git a/packages/shared/src/types/clients/msg.rs b/packages/shared/src/types/clients/msg.rs
index b9f130d..32509a8 100644
--- a/packages/shared/src/types/clients/msg.rs
+++ b/packages/shared/src/types/clients/msg.rs
@@ -31,6 +31,7 @@ pub enum ExecuteMsg {
 }
 
 /// Query messages supported by all light client contracts in ibc-lite
+#[cw_ownable::cw_ownable_query]
 #[derive(QueryResponses)]
 #[cw_serde]
 pub enum QueryMsg {
diff --git a/packages/shared/src/types/error.rs b/packages/shared/src/types/error.rs
index 8e3c23b..e2643fa 100644
--- a/packages/shared/src/types/error.rs
+++ b/packages/shared/src/types/error.rs
@@ -18,6 +18,8 @@ pub enum ContractError {
     UTF8Error(#[from] std::str::Utf8Error),
     #[error("{0}")]
     IdentifierError(#[from] ibc_core_host::types::error::IdentifierError),
+    #[error("{0}")]
+    TransferError(#[from] TransferError),
 
     #[error("unauthorized")]
     Unauthorized,
@@ -64,6 +66,15 @@ pub enum ContractError {
     RecvPacketCallbackNoResponse,
 }
 
+/// `TransferError` is the error type returned by the ics20 transfer contract.
+#[allow(missing_docs, clippy::module_name_repetitions)]
+#[non_exhaustive]
+#[derive(Error, Debug)]
+pub enum TransferError {
+    #[error("unexpected native token")]
+    UnexpectedNativeToken,
+}
+
 impl ContractError {
     /// Returns a new [`ContractError::NotFound`] with the given type name and key.
     #[must_use]