MSG Chain AMM DEX 完整实现指南
基于 CosmWasm 的 Uniswap V2 风格自动化做市商(AMM)DEX 完整实现
适用链:MSG Chain (msg-chain-1) | 共识:Round-Robin + DAR | 签名:Dilithium-5
作者:MSG Chain 开发团队
主网状态:No-Go
目录
- 概述与架构设计
- 项目结构与 Cargo 配置
- Part 1: Factory 合约
- Part 2: Pair/AMM 合约
- Part 3: Router 合约
- Part 4: LP Token 实现
- Part 5: 单元测试与集成测试
- Part 6: 前端实现 (React + TypeScript + Tailwind)
- Part 7: 部署脚本与 Docker
1. 概述与架构设计
1.1 什么是 AMM DEX?
自动化做市商(Automated Market Maker, AMM)是一种去中心化交易所(DEX)模型,使用数学公式代替传统订单簿来定价资产。本实现采用 Uniswap V2 风格的恒定乘积公式:
x * y = k
其中 x 和 y 是池中两种资产的储备量,k 是恒定乘积。在任何交易前后,乘积 k 保持不变(扣除手续费后 k 会略微增加)。
1.2 MSG Chain 特性
| 参数 | 值 |
|---|---|
| Chain ID | msg-chain-1 |
| Bech32 前缀 | msg |
| CoinType | 118 |
| 原生代币 | umsg(18 位小数) |
| Gas 价格 | 1,000,000,000 attoMSG/gas |
| 出块时间 | 5 秒 |
| 共识机制 | Round-Robin + DAR (Dynamic Awareness Routing) |
| 签名算法 | Dilithium-5(后量子密码学) |
| 智能合约 | CosmWasm 1.x |
1.3 合约架构总览
+-----------------------------------------------------------+
| DEX Frontend (React) |
+-----------------------------------------------------------+
| Router Contract |
| 多跳交换 / 流动性管理 / 路由 |
+------------------+------------------+----------------------+
| Pair A-B | Pair B-C | Pair A-C |
| (x*y=k) | (x*y=k) | (x*y=k) |
| LP Token: LP-A | LP Token: LP-B | LP Token: LP-C |
+------------------+------------------+----------------------+
| Factory Contract |
| 创建池子 / 存储配置 / 追踪所有 Pair |
+-----------------------------------------------------------+
| MSG Chain |
| CosmWasm 1.x / Dilithium-5 / RR+DAR |
+-----------------------------------------------------------+
1.4 核心公式
交换公式(输入 dx,输出 dy):
dy = (dx * y) / (x + dx) (不考虑手续费)
dy = (dx * y * 997) / (x * 1000 + dx * 997) (含 0.3% 手续费)
LP 代币铸造公式(首次提供流动性):
liquidity = sqrt(x * y) - MINIMUM_LIQUIDITY
LP 代币铸造公式(后续添加流动性):
liquidity = min(
(amount_x * total_liquidity) / reserve_x,
(amount_y * total_liquidity) / reserve_y
)
LP 代币销毁公式(移除流动性):
amount_x = (liquidity * reserve_x) / total_liquidity
amount_y = (liquidity * reserve_y) / total_liquidity
2. 项目结构与 Cargo 配置
2.1 整体项目结构
msg-dex/
├── Cargo.toml # 工作区根配置
├── contracts/
│ ├── factory/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs
│ │ ├── contract.rs
│ │ ├── state.rs
│ │ ├── msg.rs
│ │ ├── error.rs
│ │ └── response.rs
│ ├── pair/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs
│ │ ├── contract.rs
│ │ ├── state.rs
│ │ ├── msg.rs
│ │ ├── error.rs
│ │ └── math.rs
│ └── router/
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ ├── contract.rs
│ ├── state.rs
│ ├── msg.rs
│ └── error.rs
├── packages/
│ └── dex-types/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
├── scripts/
│ ├── deploy.sh
│ ├── deploy.ts
│ └── config.ts
├── frontend/
│ ├── package.json
│ ├── tsconfig.json
│ ├── tailwind.config.js
│ ├── vite.config.ts
│ └── src/
│ ├── App.tsx
│ ├── main.tsx
│ ├── hooks/
│ ├── components/
│ ├── pages/
│ └── utils/
├── docker-compose.yml
└── README.md
2.2 工作区根 Cargo.toml
[workspace]
members = [
"contracts/*",
"packages/*",
]
resolver = "2"
[workspace.package]
version = "1.0.0"
license = "MIT"
edition = "2021"
[workspace.dependencies]
cosmwasm-std = "1.5"
cosmwasm-storage = "1.5"
cosmwasm-schema = "1.5"
cw-storage-plus = "1.2"
cw2 = "1.1"
cw20 = "1.1"
cw20-base = "1.1"
cw-multi-test = "1.1"
thiserror = "1"
serde = { version = "1", features = ["derive"] }
serde-json-wasm = "1"
schemars = "0.8"
decimal = "2"
prost = "0.12"
uint = "0.9"
2.3 dex-types 包
# packages/dex-types/Cargo.toml
[package]
name = "dex-types"
version.workspace = true
license.workspace = true
edition.workspace = true
[dependencies]
cosmwasm-std.workspace = true
cw-storage-plus.workspace = true
cw20.workspace = true
serde.workspace = true
schemars.workspace = true
uint.workspace = true
// packages/dex-types/src/lib.rs
use cosmwasm_std::{Addr, Decimal, Uint128};
use cw20::Cw20Coin;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum AssetInfo {
NativeToken { denom: String },
Token { contract_addr: Addr },
}
impl fmt::Display for AssetInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AssetInfo::NativeToken { denom } => write!(f, "{}", denom),
AssetInfo::Token { contract_addr } => write!(f, "{}", contract_addr),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Asset {
pub info: AssetInfo,
pub amount: Uint128,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct PairInfo {
pub asset_infos: [AssetInfo; 2],
pub contract_addr: Addr,
pub liquidity_token: Addr,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum SwapOperation {
NativeSwap {
offer_denom: String,
ask_denom: String,
},
CosmWasmSwap {
offer_asset_info: AssetInfo,
ask_asset_info: AssetInfo,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct CumulativePrice {
pub price0_cumulative_last: Uint128,
pub price1_cumulative_last: Uint128,
pub block_timestamp_last: u64,
}
3. Part 1: Factory 合约
Factory 合约负责创建和管理所有 Pair 合约。它是 DEX 的注册中心。
3.1 Factory Cargo.toml
# contracts/factory/Cargo.toml
[package]
name = "factory"
version.workspace = true
license.workspace = true
edition.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
cosmwasm-std.workspace = true
cw-storage-plus.workspace = true
cw2.workspace = true
serde.workspace = true
schemars.workspace = true
thiserror.workspace = true
cosmwasm-schema.workspace = true
dex-types = { path = "../../packages/dex-types" }
[dev-dependencies]
cw-multi-test.workspace = true
3.2 Factory state.rs
// contracts/factory/src/state.rs
use cosmwasm_std::Addr;
use cw_storage_plus::{Item, Map};
use dex_types::AssetInfo;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Config {
pub owner: Addr,
pub fee_to: Option<Addr>,
pub pair_code_id: u64,
pub fee_bps: u16,
}
pub const CONFIG: Item<Config> = Item::new("config");
pub const PAIRS: Map<&[u8], Addr> = Map::new("pairs");
pub const PAIR_LIST: Map<u64, Addr> = Map::new("pair_list");
pub const PAIR_COUNT: Item<u64> = Item::new("pair_count");
pub fn pair_key(asset_infos: &[AssetInfo; 2]) -> Vec<u8> {
let mut key = Vec::new();
let (a, b) = if asset_infos[0] <= asset_infos[1] {
(&asset_infos[0], &asset_infos[1])
} else {
(&asset_infos[1], &asset_infos[0])
};
key.extend_from_slice(a.to_string().as_bytes());
key.push(b'-');
key.extend_from_slice(b.to_string().as_bytes());
key
}
3.3 Factory msg.rs
// contracts/factory/src/msg.rs
use cosmwasm_std::{Binary, Uint128};
use dex_types::{AssetInfo, PairInfo};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg {
pub owner: String,
pub fee_to: Option<String>,
pub pair_code_id: u64,
pub fee_bps: Option<u16>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum ExecuteMsg {
CreatePair {
asset_infos: [AssetInfo; 2],
init_params: Option<Binary>,
},
UpdateConfig {
owner: Option<String>,
fee_to: Option<String>,
fee_bps: Option<u16>,
pair_code_id: Option<u64>,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum QueryMsg {
Pair { asset_infos: [AssetInfo; 2] },
Pairs { start_after: Option<[AssetInfo; 2]>, limit: Option<u32> },
Config {},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct PairResponse {
pub asset_infos: [AssetInfo; 2],
pub contract_addr: String,
pub liquidity_token: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct PairsResponse {
pub pairs: Vec<PairResponse>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ConfigResponse {
pub owner: String,
pub fee_to: Option<String>,
pub pair_code_id: u64,
pub fee_bps: u16,
}
pub const REPLY_CREATE_PAIR: u64 = 1;
3.4 Factory error.rs
// contracts/factory/src/error.rs
use cosmwasm_std::StdError;
use thiserror::Error;
#[derive(Error, Debug, PartialEq)]
pub enum ContractError {
#[error("{0}")]
Std(#[from] StdError),
#[error("Unauthorized")]
Unauthorized {},
#[error("Pair already exists")]
PairAlreadyExists {},
#[error("Duplicate assets")]
DuplicateAssets {},
#[error("Invalid fee bps: {fee_bps}, max allowed: {max}")]
InvalidFeeBps { fee_bps: u16, max: u16 },
#[error("Pair code ID not set")]
PairCodeIdNotSet {},
#[error("Invalid reply: {msg}")]
InvalidReply { msg: String },
}
3.5 Factory contract.rs
// contracts/factory/src/contract.rs
use cosmwasm_std::{
entry_point, to_binary, Binary, Deps, DepsMut, Empty, Env, MessageInfo,
Order, Reply, Response, SubMsg, WasmMsg,
};
use cw2::set_contract_version;
use dex_types::{AssetInfo, PairInfo};
use crate::error::ContractError;
use crate::msg::{
ConfigResponse, ExecuteMsg, InstantiateMsg, PairResponse, PairsResponse,
QueryMsg, REPLY_CREATE_PAIR,
};
use crate::state::{pair_key, Config, CONFIG, PAIRS, PAIR_LIST, PAIR_COUNT};
const CONTRACT_NAME: &str = "msg-dex-factory";
const CONTRACT_VERSION: &str = "1.0.0";
const MAX_FEE_BPS: u16 = 1000;
const DEFAULT_FEE_BPS: u16 = 30;
#[entry_point]
pub fn instantiate(
deps: DepsMut,
_env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> Result<Response, ContractError> {
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
let owner = deps.api.addr_validate(&msg.owner)?;
let fee_to = match msg.fee_to {
Some(addr) => Some(deps.api.addr_validate(&addr)?),
None => None,
};
let fee_bps = msg.fee_bps.unwrap_or(DEFAULT_FEE_BPS);
if fee_bps > MAX_FEE_BPS {
return Err(ContractError::InvalidFeeBps { fee_bps, max: MAX_FEE_BPS });
}
let config = Config { owner, fee_to, pair_code_id: msg.pair_code_id, fee_bps };
CONFIG.save(deps.storage, &config)?;
PAIR_COUNT.save(deps.storage, &0u64)?;
Ok(Response::new()
.add_attribute("method", "instantiate")
.add_attribute("owner", &config.owner))
}
#[entry_point]
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response, ContractError> {
match msg {
ExecuteMsg::CreatePair { asset_infos, init_params: _ } => {
execute_create_pair(deps, env, info, asset_infos)
}
ExecuteMsg::UpdateConfig { owner, fee_to, fee_bps, pair_code_id } => {
execute_update_config(deps, info, owner, fee_to, fee_bps, pair_code_id)
}
}
}
pub fn execute_create_pair(
deps: DepsMut,
env: Env,
_info: MessageInfo,
asset_infos: [AssetInfo; 2],
) -> Result<Response, ContractError> {
if asset_infos[0] == asset_infos[1] {
return Err(ContractError::DuplicateAssets {});
}
let sorted = if asset_infos[0] <= asset_infos[1] {
asset_infos
} else {
[asset_infos[1].clone(), asset_infos[0].clone()]
};
let key = pair_key(&sorted);
if PAIRS.has(deps.storage, &key) {
return Err(ContractError::PairAlreadyExists {});
}
let config = CONFIG.load(deps.storage)?;
if config.pair_code_id == 0 {
return Err(ContractError::PairCodeIdNotSet {});
}
let init_msg = dex_types::pair::InstantiateMsg {
asset_infos: sorted.clone(),
factory_addr: env.contract.address.to_string(),
lp_token_code_id: 0,
fee_bps: config.fee_bps,
};
let msg = WasmMsg::Instantiate {
admin: Some(config.owner.to_string()),
code_id: config.pair_code_id,
msg: to_binary(&init_msg)?,
funds: vec![],
label: format!("MSG-DEX-Pair-{}-{}", sorted[0].to_string(), sorted[1].to_string()),
};
let sub_msg = SubMsg::reply_on_success(msg, REPLY_CREATE_PAIR);
cw_storage_plus::Item::new("pending_pair").save(deps.storage, &sorted)?;
Ok(Response::new()
.add_submessage(sub_msg)
.add_attribute("method", "create_pair")
.add_attribute("asset_0", sorted[0].to_string())
.add_attribute("asset_1", sorted[1].to_string()))
}
pub fn execute_update_config(
deps: DepsMut,
info: MessageInfo,
owner: Option<String>,
fee_to: Option<String>,
fee_bps: Option<u16>,
pair_code_id: Option<u64>,
) -> Result<Response, ContractError> {
let mut config = CONFIG.load(deps.storage)?;
if info.sender != config.owner {
return Err(ContractError::Unauthorized {});
}
if let Some(owner) = owner { config.owner = deps.api.addr_validate(&owner)?; }
if let Some(fee_to) = fee_to { config.fee_to = Some(deps.api.addr_validate(&fee_to)?); }
if let Some(fee_bps) = fee_bps {
if fee_bps > MAX_FEE_BPS { return Err(ContractError::InvalidFeeBps { fee_bps, max: MAX_FEE_BPS }); }
config.fee_bps = fee_bps;
}
if let Some(pair_code_id) = pair_code_id { config.pair_code_id = pair_code_id; }
CONFIG.save(deps.storage, &config)?;
Ok(Response::new()
.add_attribute("method", "update_config")
.add_attribute("owner", config.owner.to_string()))
}
#[entry_point]
pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result<Response, ContractError> {
match msg.id {
REPLY_CREATE_PAIR => handle_create_pair_reply(deps, msg),
_ => Err(ContractError::InvalidReply { msg: format!("Unknown reply id: {}", msg.id) }),
}
}
fn handle_create_pair_reply(deps: DepsMut, msg: Reply) -> Result<Response, ContractError> {
let pair_contract = msg.result.into_result()
.map_err(|e| ContractError::InvalidReply { msg: format!("Reply error: {}", e) })?
.get_contract_address()
.ok_or_else(|| ContractError::InvalidReply { msg: "No contract address".to_string() })?;
let pair_addr = deps.api.addr_validate(&pair_contract)?;
let asset_infos: [AssetInfo; 2] = cw_storage_plus::Item::new("pending_pair").load(deps.storage)?;
let pair_info: PairInfo = deps.querier.query_wasm_smart(&pair_addr, &dex_types::pair::QueryMsg::PairInfo {})?;
let key = pair_key(&asset_infos);
PAIRS.save(deps.storage, &key, &pair_addr)?;
let count = PAIR_COUNT.load(deps.storage)?;
PAIR_LIST.save(deps.storage, &count, &pair_addr)?;
PAIR_COUNT.save(deps.storage, &(count + 1))?;
Ok(Response::new()
.add_attribute("method", "create_pair_reply")
.add_attribute("pair_contract", pair_contract)
.add_attribute("liquidity_token", pair_info.liquidity_token.to_string()))
}
#[entry_point]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> Result<Binary, ContractError> {
match msg {
QueryMsg::Pair { asset_infos } => Ok(to_binary(&query_pair(deps, asset_infos)?)?),
QueryMsg::Pairs { start_after, limit } => Ok(to_binary(&query_pairs(deps, start_after, limit)?)?),
QueryMsg::Config {} => Ok(to_binary(&query_config(deps)?)?),
}
}
fn query_pair(deps: Deps, asset_infos: [AssetInfo; 2]) -> Result<PairResponse, ContractError> {
let sorted = if asset_infos[0] <= asset_infos[1] { asset_infos } else { [asset_infos[1].clone(), asset_infos[0].clone()] };
let key = pair_key(&sorted);
let pair_addr = PAIRS.load(deps.storage, &key)?;
let pair_info: PairInfo = deps.querier.query_wasm_smart(&pair_addr, &dex_types::pair::QueryMsg::PairInfo {})?;
Ok(PairResponse { asset_infos: sorted, contract_addr: pair_info.contract_addr.to_string(), liquidity_token: pair_info.liquidity_token.to_string() })
}
fn query_pairs(deps: Deps, _start_after: Option<[AssetInfo; 2]>, limit: Option<u32>) -> Result<PairsResponse, ContractError> {
let limit = limit.unwrap_or(30).min(100) as usize;
let pairs: Vec<PairResponse> = PAIR_LIST
.range(deps.storage, None, None, Order::Ascending)
.take(limit)
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.map(|(_, addr)| -> Result<PairResponse, ContractError> {
let pair_info: PairInfo = deps.querier.query_wasm_smart(&addr, &dex_types::pair::QueryMsg::PairInfo {})?;
Ok(PairResponse { asset_infos: pair_info.asset_infos, contract_addr: pair_info.contract_addr.to_string(), liquidity_token: pair_info.liquidity_token.to_string() })
})
.collect::<Result<Vec<_>, _>>()?;
Ok(PairsResponse { pairs })
}
fn query_config(deps: Deps) -> Result<ConfigResponse, ContractError> {
let config = CONFIG.load(deps.storage)?;
Ok(ConfigResponse { owner: config.owner.to_string(), fee_to: config.fee_to.map(|a| a.to_string()), pair_code_id: config.pair_code_id, fee_bps: config.fee_bps })
}
3.6 Factory lib.rs
// contracts/factory/src/lib.rs
pub mod contract;
pub mod error;
pub mod msg;
pub mod state;
pub use crate::error::ContractError;
4. Part 2: Pair/AMM 合约
这是整个 DEX 的核心。Pair 合约实现了恒定乘积 AMM 算法,管理流动性池和 LP 代币。
4.1 Pair Cargo.toml
# contracts/pair/Cargo.toml
[package]
name = "pair"
version.workspace = true
license.workspace = true
edition.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
cosmwasm-std.workspace = true
cw-storage-plus.workspace = true
cw2.workspace = true
cw20.workspace = true
cw20-base = { workspace = true, features = ["library"] }
serde.workspace = true
schemars.workspace = true
thiserror.workspace = true
cosmwasm-schema.workspace = true
dex-types = { path = "../../packages/dex-types" }
uint.workspace = true
[dev-dependencies]
cw-multi-test.workspace = true
4.2 Pair state.rs
// contracts/pair/src/state.rs
use cosmwasm_std::{Addr, Uint128};
use cw_storage_plus::Item;
use dex_types::AssetInfo;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Config {
pub factory_addr: Addr,
pub asset_infos: [AssetInfo; 2],
pub lp_token_addr: Addr,
pub fee_bps: u16,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct PoolState {
pub reserve_0: Uint128,
pub reserve_1: Uint128,
pub total_liquidity: Uint128,
pub price0_cumulative_last: Uint128,
pub price1_cumulative_last: Uint128,
pub block_timestamp_last: u64,
}
pub const CONFIG: Item<Config> = Item::new("config");
pub const POOL_STATE: Item<PoolState> = Item::new("pool_state");
pub const MINIMUM_LIQUIDITY: Uint128 = Uint128::new(1000);
4.3 Pair msg.rs
// contracts/pair/src/msg.rs
use cosmwasm_std::{Binary, Decimal, Uint128};
use dex_types::Asset;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg {
pub asset_infos: [dex_types::AssetInfo; 2],
pub factory_addr: String,
pub lp_token_code_id: u64,
pub fee_bps: u16,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum ExecuteMsg {
Swap {
offer_asset: Asset,
belief_price: Option<Decimal>,
max_spread: Option<Decimal>,
to: Option<String>,
},
ProvideLiquidity {
assets: [Asset; 2],
slippage_tolerance: Option<Decimal>,
auto_stake: Option<String>,
receiver: Option<String>,
},
WithdrawLiquidity {
amount: Uint128,
min_assets_to_withdraw: Option<[Asset; 2]>,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum QueryMsg {
Pool {},
Simulation { offer_asset: Asset },
ReverseSimulation { ask_asset: Asset },
PairInfo {},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct PoolResponse {
pub assets: [dex_types::Asset; 2],
pub total_liquidity: Uint128,
pub price0_cumulative_last: Uint128,
pub price1_cumulative_last: Uint128,
pub block_timestamp_last: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct SimulationResponse {
pub return_amount: Uint128,
pub spread_amount: Uint128,
pub commission_amount: Uint128,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ReverseSimulationResponse {
pub offer_amount: Uint128,
pub spread_amount: Uint128,
pub commission_amount: Uint128,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct PairInfoResponse {
pub asset_infos: [dex_types::AssetInfo; 2],
pub contract_addr: String,
pub liquidity_token: String,
}
4.4 Pair error.rs
// contracts/pair/src/error.rs
use cosmwasm_std::{StdError, Uint128};
use thiserror::Error;
#[derive(Error, Debug, PartialEq)]
pub enum ContractError {
#[error("{0}")]
Std(#[from] StdError),
#[error("Unauthorized")]
Unauthorized {},
#[error("Insufficient liquidity: {available} < {required}")]
InsufficientLiquidity { available: Uint128, required: Uint128 },
#[error("Insufficient output amount: {available} < {required}")]
InsufficientOutputAmount { available: Uint128, required: Uint128 },
#[error("Slippage tolerance exceeded: {slippage} > {tolerance}")]
SlippageExceeded { slippage: String, tolerance: String },
#[error("Max spread exceeded: {spread} > {max_spread}")]
MaxSpreadExceeded { spread: String, max_spread: String },
#[error("Minimum liquidity not met")]
MinimumLiquidityNotMet {},
#[error("Invalid offer asset")]
InvalidOfferAsset {},
#[error("Asset mismatch")]
AssetMismatch {},
#[error("Zero amount not allowed")]
ZeroAmount {},
#[error("Doubling assets not allowed")]
DoublingAssets {},
#[error("Native token balance mismatch")]
NativeBalanceMismatch {},
#[error("CW20 balance mismatch")]
Cw20BalanceMismatch {},
}
4.5 Pair math.rs (AMM 核心算法)
// contracts/pair/src/math.rs
use cosmwasm_std::{Decimal, Uint128};
use std::cmp::Ordering;
/// 恒定乘积 AMM 数学实现
/// swap 计算公式:dy = (dx * y * (10000 - fee_bps)) / (x * 10000 + dx * (10000 - fee_bps))
pub fn compute_swap(
amount_in: Uint128,
reserve_in: Uint128,
reserve_out: Uint128,
fee_bps: u16,
) -> Result<(Uint128, Uint128, Uint128), String> {
if amount_in.is_zero() {
return Err("Amount in cannot be zero".to_string());
}
if reserve_in.is_zero() || reserve_out.is_zero() {
return Err("Insufficient liquidity".to_string());
}
let fee_multiplier = Uint128::from(10000u64 - fee_bps as u64);
let fee_divisor = Uint128::from(10000u64);
let amount_in_with_fee = amount_in.checked_mul(fee_multiplier)?;
let denominator = reserve_in.checked_mul(fee_divisor)?.checked_add(amount_in_with_fee)?;
let numerator = amount_in_with_fee.checked_mul(reserve_out)?;
let return_amount = numerator.checked_div(denominator)?;
let ideal_output = amount_in.checked_mul(reserve_out)?.checked_div(reserve_in)?;
let spread_amount = ideal_output.checked_sub(return_amount)?;
let commission_amount = amount_in.checked_mul(Uint128::from(fee_bps as u64))?.checked_div(Uint128::from(10000u64))?;
Ok((return_amount, spread_amount, commission_amount))
}
/// 反向计算:给定输出量,计算所需输入量
pub fn compute_swap_reverse(
amount_out: Uint128,
reserve_in: Uint128,
reserve_out: Uint128,
fee_bps: u16,
) -> Result<(Uint128, Uint128, Uint128), String> {
if amount_out.is_zero() {
return Err("Amount out cannot be zero".to_string());
}
if reserve_in.is_zero() || reserve_out.is_zero() {
return Err("Insufficient liquidity".to_string());
}
if amount_out >= reserve_out {
return Err("Amount out exceeds reserve".to_string());
}
let fee_multiplier = Uint128::from(10000u64 - fee_bps as u64);
let numerator = reserve_in.checked_mul(amount_out)?.checked_mul(Uint128::from(10000u64))?;
let denominator = reserve_out.checked_sub(amount_out)?.checked_mul(fee_multiplier)?;
let offer_amount = numerator.checked_div(denominator)?.checked_add(Uint128::one())?;
let (return_amount, spread_amount, commission_amount) =
compute_swap(offer_amount, reserve_in, reserve_out, fee_bps)?;
Ok((offer_amount, spread_amount, commission_amount))
}
/// 计算 LP 代币铸造量
pub fn compute_lp_tokens_to_mint(
amount_0: Uint128,
amount_1: Uint128,
reserve_0: Uint128,
reserve_1: Uint128,
total_liquidity: Uint128,
minimum_liquidity: Uint128,
) -> Result<Uint128, String> {
if total_liquidity.is_zero() {
// 首次提供流动性:liquidity = sqrt(x * y) - MINIMUM_LIQUIDITY
let sqrt_prod = (amount_0.u128() as u128).checked_mul(amount_1.u128() as u128)
.map(|prod| (prod as f64).sqrt() as u128)
.ok_or("Overflow in sqrt calculation")?;
let liquidity = Uint128::try_from(sqrt_prod).map_err(|_| "Conversion error")?;
if liquidity < minimum_liquidity {
return Err("Minimum liquidity not met".to_string());
}
Ok(liquidity.checked_sub(minimum_liquidity)?)
} else {
// 后续添加:liquidity = min(amount_x * total / reserve_x, amount_y * total / reserve_y)
let liquidity_0 = amount_0.checked_mul(total_liquidity)? / reserve_0;
let liquidity_1 = amount_1.checked_mul(total_liquidity)? / reserve_1;
Ok(std::cmp::min(liquidity_0, liquidity_1))
}
}
/// 计算 LP 代币销毁后应返回的资产量
pub fn compute_withdraw_amounts(
liquidity: Uint128,
reserve_0: Uint128,
reserve_1: Uint128,
total_liquidity: Uint128,
) -> Result<(Uint128, Uint128), String> {
if liquidity > total_liquidity {
return Err("Liquidity exceeds total".to_string());
}
let amount_0 = liquidity.checked_mul(reserve_0)? / total_liquidity;
let amount_1 = liquidity.checked_mul(reserve_1)? / total_liquidity;
Ok((amount_0, amount_1))
}
/// 检查滑点容忍度
pub fn check_slippage(expected: Uint128, actual: Uint128, tolerance: Decimal) -> Result<(), String> {
if expected.is_zero() { return Ok(()); }
let slippage = Decimal::from_ratio(expected.checked_sub(actual).unwrap_or_default(), expected);
if slippage > tolerance {
return Err(format!("Slippage exceeded: {} > {}", slippage, tolerance));
}
Ok(())
}
/// 计算价格(用于 TWAP 累计)
pub fn compute_price(reserve_0: Uint128, reserve_1: Uint128) -> (Uint128, Uint128) {
(reserve_1, reserve_0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_swap_zero_amount() {
assert!(compute_swap(Uint128::zero(), Uint128::new(1000), Uint128::new(1000), 30).is_err());
}
#[test]
fn test_compute_swap_no_liquidity() {
assert!(compute_swap(Uint128::new(100), Uint128::zero(), Uint128::new(1000), 30).is_err());
}
#[test]
fn test_compute_swap_basic() {
let (amount, _spread, commission) =
compute_swap(Uint128::new(100), Uint128::new(1000), Uint128::new(2000), 30).unwrap();
assert!(!amount.is_zero());
assert!(amount <= Uint128::new(200));
assert_eq!(commission, Uint128::new(3));
}
#[test]
fn test_compute_swap_reverse() {
let (offer_amount, _, _) = compute_swap_reverse(
Uint128::new(181), Uint128::new(1000), Uint128::new(2000), 30,
).unwrap();
assert!(!offer_amount.is_zero());
}
#[test]
fn test_compute_lp_tokens_mint_initial() {
let result = compute_lp_tokens_to_mint(
Uint128::new(1000), Uint128::new(2000),
Uint128::zero(), Uint128::zero(), Uint128::zero(), Uint128::new(1000),
).unwrap();
assert_eq!(result, Uint128::new(414));
}
#[test]
fn test_compute_lp_tokens_mint_subsequent() {
let result = compute_lp_tokens_to_mint(
Uint128::new(500), Uint128::new(1000),
Uint128::new(1000), Uint128::new(2000), Uint128::new(10000), Uint128::new(1000),
).unwrap();
assert_eq!(result, Uint128::new(5000));
}
#[test]
fn test_compute_withdraw_amounts() {
let (amount_0, amount_1) = compute_withdraw_amounts(
Uint128::new(5000), Uint128::new(1000), Uint128::new(2000), Uint128::new(10000),
).unwrap();
assert_eq!(amount_0, Uint128::new(500));
assert_eq!(amount_1, Uint128::new(1000));
}
#[test]
fn test_compute_swap_known_vector() {
// Uniswap V2 已知测试向量
// x: 100000 DAI, y: 100000 USDC, dx: 1000 DAI
// dy = 1000 * 100000 * 997 / (100000 * 1000 + 1000 * 997) = 987
let (amount, _, _) = compute_swap(
Uint128::new(1000), Uint128::new(100000), Uint128::new(100000), 30,
).unwrap();
assert_eq!(amount, Uint128::new(987));
}
#[test]
fn test_swap_symmetry_pool() {
let (result, _, _) = compute_swap(
Uint128::new(1000), Uint128::new(10000), Uint128::new(10000), 30,
).unwrap();
assert!(result < Uint128::new(1000));
assert!(result > Uint128::new(800));
}
#[test]
fn test_swap_reverse_roundtrip() {
let (amount_out, _, _) = compute_swap(Uint128::new(1000), Uint128::new(100000), Uint128::new(100000), 30).unwrap();
let (offer_amount, _, _) = compute_swap_reverse(amount_out, Uint128::new(100000), Uint128::new(100000), 30).unwrap();
let diff = if offer_amount > Uint128::new(1000) { offer_amount - Uint128::new(1000) } else { Uint128::new(1000) - offer_amount };
assert!(diff <= Uint128::new(1));
}
#[test]
fn test_minimum_liquidity_enforced() {
assert!(compute_lp_tokens_to_mint(
Uint128::new(100), Uint128::new(100),
Uint128::zero(), Uint128::zero(), Uint128::zero(), Uint128::new(1000),
).is_err());
}
#[test]
fn test_zero_fee_swap() {
let (amount, _, commission) = compute_swap(
Uint128::new(1000), Uint128::new(100000), Uint128::new(100000), 0,
).unwrap();
assert_eq!(commission, Uint128::zero());
assert!(!amount.is_zero());
}
#[test]
fn test_high_fee_swap() {
let (amount, _, commission) = compute_swap(
Uint128::new(1000), Uint128::new(100000), Uint128::new(100000), 1000,
).unwrap();
assert_eq!(commission, Uint128::new(100));
assert!(amount < Uint128::new(1000));
}
#[test]
fn test_overflow_safe_large_numbers() {
let result = compute_swap(
Uint128::new(1_000_000_000_000_000_000u128),
Uint128::new(1_000_000_000_000_000_000u128),
Uint128::new(1_000_000_000_000_000_000u128),
30,
);
assert!(result.is_ok());
}
#[test]
fn test_compute_swap_reverse_exact_amount_out() {
let (offer_amount, _, _) = compute_swap_reverse(
Uint128::new(500), Uint128::new(100000), Uint128::new(100000), 30,
).unwrap();
let (back, _, _) = compute_swap(offer_amount, Uint128::new(100000), Uint128::new(100000), 30).unwrap();
assert_eq!(back, Uint128::new(500));
}
}
4.6 Pair contract.rs
// contracts/pair/src/contract.rs
use cosmwasm_std::{
entry_point, to_binary, BankMsg, Binary, Coin, CosmosMsg, Decimal, Deps, DepsMut,
Env, MessageInfo, Reply, Response, StdError, SubMsg, Uint128, WasmMsg,
};
use cw20::{Cw20ExecuteMsg, Cw20QueryMsg, TokenInfoResponse};
use cw20_base::msg::InstantiateMsg as Cw20InstantiateMsg;
use cw2::set_contract_version;
use dex_types::{Asset, AssetInfo, PairInfo};
use crate::error::ContractError;
use crate::math::{
check_slippage, compute_lp_tokens_to_mint, compute_price, compute_swap,
compute_swap_reverse, compute_withdraw_amounts,
};
use crate::msg::{
ExecuteMsg, InstantiateMsg, PairInfoResponse, PoolResponse, QueryMsg,
ReverseSimulationResponse, SimulationResponse,
};
use crate::state::{Config, PoolState, CONFIG, MINIMUM_LIQUIDITY, POOL_STATE};
const CONTRACT_NAME: &str = "msg-dex-pair";
const CONTRACT_VERSION: &str = "1.0.0";
pub const REPLY_CREATE_LP_TOKEN: u64 = 1;
#[entry_point]
pub fn instantiate(
deps: DepsMut,
env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> Result<Response, ContractError> {
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
if msg.asset_infos[0] == msg.asset_infos[1] {
return Err(ContractError::DoublingAssets {});
}
let config = Config {
factory_addr: deps.api.addr_validate(&msg.factory_addr)?,
asset_infos: msg.asset_infos,
lp_token_addr: Addr::unchecked(""),
fee_bps: msg.fee_bps,
};
CONFIG.save(deps.storage, &config)?;
let pool_state = PoolState {
reserve_0: Uint128::zero(),
reserve_1: Uint128::zero(),
total_liquidity: Uint128::zero(),
price0_cumulative_last: Uint128::zero(),
price1_cumulative_last: Uint128::zero(),
block_timestamp_last: 0,
};
POOL_STATE.save(deps.storage, &pool_state)?;
let label = format!("MSG-DEX-LP-{}-{}", config.asset_infos[0].to_string(), config.asset_infos[1].to_string());
let cw20_init_msg = Cw20InstantiateMsg {
name: format!("MSG DEX LP {}", label),
symbol: format!("MLP-{}-{}",
config.asset_infos[0].to_string().chars().take(3).collect::<String>().to_uppercase(),
config.asset_infos[1].to_string().chars().take(3).collect::<String>().to_uppercase()),
decimals: 18,
initial_balances: vec![],
mint: Some(cw20::MinterResponse { minter: env.contract.address.to_string(), cap: None }),
marketing: None,
};
let instantiate_msg = WasmMsg::Instantiate {
admin: Some(env.contract.address.to_string()),
code_id: msg.lp_token_code_id,
msg: to_binary(&cw20_init_msg)?,
funds: vec![],
label,
};
Ok(Response::new()
.add_submessage(SubMsg::reply_on_success(instantiate_msg, REPLY_CREATE_LP_TOKEN))
.add_attribute("method", "instantiate")
.add_attribute("factory", msg.factory_addr))
}
#[entry_point]
pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result<Response, ContractError> {
match msg.id {
REPLY_CREATE_LP_TOKEN => {
let lp_token_addr = msg.result.into_result()
.map_err(|e| ContractError::Std(StdError::generic_err(e)))?
.get_contract_address()
.ok_or_else(|| ContractError::Std(StdError::generic_err("No contract address")))?;
let mut config = CONFIG.load(deps.storage)?;
config.lp_token_addr = deps.api.addr_validate(&lp_token_addr)?;
CONFIG.save(deps.storage, &config)?;
Ok(Response::new().add_attribute("lp_token_addr", lp_token_addr))
}
_ => Err(ContractError::Std(StdError::generic_err(format!("Unknown reply id: {}", msg.id)))),
}
}
#[entry_point]
pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> Result<Response, ContractError> {
match msg {
ExecuteMsg::Swap { offer_asset, belief_price, max_spread, to } => {
execute_swap(deps, env, info, offer_asset, belief_price, max_spread, to)
}
ExecuteMsg::ProvideLiquidity { assets, slippage_tolerance, auto_stake: _, receiver } => {
execute_provide_liquidity(deps, env, info, assets, slippage_tolerance, receiver)
}
ExecuteMsg::WithdrawLiquidity { amount, min_assets_to_withdraw } => {
execute_withdraw_liquidity(deps, env, info, amount, min_assets_to_withdraw)
}
}
}
pub fn execute_swap(
deps: DepsMut,
env: Env,
info: MessageInfo,
offer_asset: Asset,
belief_price: Option<Decimal>,
max_spread: Option<Decimal>,
to: Option<String>,
) -> Result<Response, ContractError> {
if offer_asset.amount.is_zero() { return Err(ContractError::ZeroAmount {}); }
let config = CONFIG.load(deps.storage)?;
let mut pool_state = POOL_STATE.load(deps.storage)?;
let (offer_reserve, ask_reserve, is_asset_0) = if offer_asset.info == config.asset_infos[0] {
(pool_state.reserve_0, pool_state.reserve_1, true)
} else if offer_asset.info == config.asset_infos[1] {
(pool_state.reserve_1, pool_state.reserve_0, false)
} else {
return Err(ContractError::InvalidOfferAsset {});
};
// 验证收到的资产
match &offer_asset.info {
AssetInfo::NativeToken { denom } => {
let received = info.funds.iter().find(|c| c.denom == *denom).map(|c| c.amount).unwrap_or(Uint128::zero());
if received < offer_asset.amount { return Err(ContractError::NativeBalanceMismatch {}); }
}
AssetInfo::Token { .. } => {}
}
let (return_amount, spread_amount, commission_amount) = compute_swap(
offer_asset.amount, offer_reserve, ask_reserve, config.fee_bps,
).map_err(|e| ContractError::Std(StdError::generic_err(e)))?;
// 滑点/价格保护检查
if let Some(max_spread) = max_spread {
let max_allowed = Decimal::from_ratio(ask_reserve, offer_reserve).checked_mul(max_spread)
.map_err(|_| ContractError::Std(StdError::generic_err("overflow")))?.to_uint_floor();
if spread_amount > max_allowed {
return Err(ContractError::MaxSpreadExceeded { spread: spread_amount.to_string(), max_spread: max_allowed.to_string() });
}
}
if let Some(belief_price) = belief_price {
let executed_price = Decimal::from_ratio(return_amount, offer_asset.amount);
if executed_price < belief_price {
return Err(ContractError::SlippageExceeded { slippage: executed_price.to_string(), tolerance: belief_price.to_string() });
}
}
// 更新储备
if is_asset_0 {
pool_state.reserve_0 = pool_state.reserve_0.checked_add(offer_asset.amount)?;
pool_state.reserve_1 = pool_state.reserve_1.checked_sub(return_amount)?;
} else {
pool_state.reserve_1 = pool_state.reserve_1.checked_add(offer_asset.amount)?;
pool_state.reserve_0 = pool_state.reserve_0.checked_sub(return_amount)?;
}
// 更新 TWAP 价格累计
if pool_state.block_timestamp_last > 0 {
let time_elapsed = env.block.time.seconds() - pool_state.block_timestamp_last;
if time_elapsed > 0 && !pool_state.reserve_0.is_zero() && !pool_state.reserve_1.is_zero() {
pool_state.price0_cumulative_last = pool_state.price0_cumulative_last
.checked_add(pool_state.reserve_1.checked_mul(Uint128::from(time_elapsed))? / pool_state.reserve_0)?;
pool_state.price1_cumulative_last = pool_state.price1_cumulative_last
.checked_add(pool_state.reserve_0.checked_mul(Uint128::from(time_elapsed))? / pool_state.reserve_1)?;
}
}
pool_state.block_timestamp_last = env.block.time.seconds();
POOL_STATE.save(deps.storage, &pool_state)?;
let receiver = deps.api.addr_validate(&to.unwrap_or_else(|| info.sender.to_string()))?;
let output_idx = if is_asset_0 { 1 } else { 0 };
let transfer_msg = match &config.asset_infos[output_idx] {
AssetInfo::NativeToken { denom } => {
CosmosMsg::Bank(BankMsg::Send { to_address: receiver.to_string(), assets: vec![Coin { denom: denom.clone(), amount: return_amount }] })
}
AssetInfo::Token { contract_addr } => {
CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: contract_addr.to_string(),
msg: to_binary(&Cw20ExecuteMsg::Transfer { recipient: receiver.to_string(), amount: return_amount })?,
funds: vec![],
})
}
};
Ok(Response::new()
.add_message(transfer_msg)
.add_attribute("method", "swap")
.add_attribute("offer_amount", offer_asset.amount)
.add_attribute("return_amount", return_amount)
.add_attribute("spread_amount", spread_amount)
.add_attribute("commission_amount", commission_amount))
}
pub fn execute_provide_liquidity(
deps: DepsMut,
env: Env,
info: MessageInfo,
assets: [Asset; 2],
slippage_tolerance: Option<Decimal>,
receiver: Option<String>,
) -> Result<Response, ContractError> {
let config = CONFIG.load(deps.storage)?;
let mut pool_state = POOL_STATE.load(deps.storage)?;
if assets[0].info != config.asset_infos[0] || assets[1].info != config.asset_infos[1] {
return Err(ContractError::AssetMismatch {});
}
if assets[0].amount.is_zero() || assets[1].amount.is_zero() {
return Err(ContractError::ZeroAmount {});
}
let (amount_0, amount_1) = if pool_state.total_liquidity.is_zero() {
(assets[0].amount, assets[1].amount)
} else {
let expected_1 = assets[0].amount.checked_mul(pool_state.reserve_1)?.checked_div(pool_state.reserve_0)?;
if assets[1].amount < expected_1 {
if let Some(tolerance) = slippage_tolerance {
let ratio = Decimal::from_ratio(assets[1].amount, expected_1);
let min_ratio = Decimal::one().checked_sub(tolerance).map_err(|_| StdError::generic_err("overflow"))?;
if ratio < min_ratio {
return Err(ContractError::SlippageExceeded { slippage: ratio.to_string(), tolerance: min_ratio.to_string() });
}
}
(assets[0].amount, assets[1].amount)
} else {
(assets[0].amount, expected_1)
}
};
let liquidity = compute_lp_tokens_to_mint(amount_0, amount_1, pool_state.reserve_0, pool_state.reserve_1, pool_state.total_liquidity, MINIMUM_LIQUIDITY)
.map_err(|e| ContractError::Std(StdError::generic_err(e)))?;
pool_state.reserve_0 = pool_state.reserve_0.checked_add(amount_0)?;
pool_state.reserve_1 = pool_state.reserve_1.checked_add(amount_1)?;
pool_state.total_liquidity = pool_state.total_liquidity.checked_add(liquidity)?;
POOL_STATE.save(deps.storage, &pool_state)?;
let recipient = receiver.unwrap_or_else(|| info.sender.to_string());
let mint_msg = Cw20ExecuteMsg::Mint { recipient, amount: liquidity };
let mut response = Response::new()
.add_message(WasmMsg::Execute {
contract_addr: config.lp_token_addr.to_string(),
msg: to_binary(&mint_msg)?, funds: vec![],
})
.add_attribute("method", "provide_liquidity")
.add_attribute("amount_0", amount_0)
.add_attribute("amount_1", amount_1)
.add_attribute("liquidity", liquidity);
// 首次流动性时销毁 MINIMUM_LIQUIDITY
if pool_state.total_liquidity == liquidity.checked_add(MINIMUM_LIQUIDITY)? {
let burn_msg = Cw20ExecuteMsg::Burn { amount: MINIMUM_LIQUIDITY };
response = response.add_message(WasmMsg::Execute {
contract_addr: config.lp_token_addr.to_string(),
msg: to_binary(&burn_msg)?, funds: vec![],
}).add_attribute("is_initial", "true");
}
Ok(response)
}
pub fn execute_withdraw_liquidity(
deps: DepsMut,
_env: Env,
info: MessageInfo,
amount: Uint128,
min_assets_to_withdraw: Option<[Asset; 2]>,
) -> Result<Response, ContractError> {
if amount.is_zero() { return Err(ContractError::ZeroAmount {}); }
let config = CONFIG.load(deps.storage)?;
let mut pool_state = POOL_STATE.load(deps.storage)?;
let (withdraw_0, withdraw_1) = compute_withdraw_amounts(amount, pool_state.reserve_0, pool_state.reserve_1, pool_state.total_liquidity)
.map_err(|e| ContractError::Std(StdError::generic_err(e)))?;
// 滑点保护
if let Some(min_assets) = min_assets_to_withdraw {
let min_0 = min_assets.iter().find(|a| a.info == config.asset_infos[0]).map(|a| a.amount).unwrap_or(Uint128::zero());
let min_1 = min_assets.iter().find(|a| a.info == config.asset_infos[1]).map(|a| a.amount).unwrap_or(Uint128::zero());
if withdraw_0 < min_0 || withdraw_1 < min_1 {
return Err(ContractError::InsufficientOutputAmount {
available: if withdraw_0 < min_0 { withdraw_0 } else { withdraw_1 },
required: if withdraw_0 < min_0 { min_0 } else { min_1 },
});
}
}
pool_state.reserve_0 = pool_state.reserve_0.checked_sub(withdraw_0)?;
pool_state.reserve_1 = pool_state.reserve_1.checked_sub(withdraw_1)?;
pool_state.total_liquidity = pool_state.total_liquidity.checked_sub(amount)?;
POOL_STATE.save(deps.storage, &pool_state)?;
// 销毁 LP 代币 + 返回资产
let burn_msg = Cw20ExecuteMsg::BurnFrom { owner: info.sender.to_string(), amount };
let mut messages: Vec<CosmosMsg> = vec![
CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: config.lp_token_addr.to_string(),
msg: to_binary(&burn_msg)?, funds: vec![],
}),
];
for (i, &withdraw_amount) in [withdraw_0, withdraw_1].iter().enumerate() {
let msg: CosmosMsg = match &config.asset_infos[i] {
AssetInfo::NativeToken { denom } => {
CosmosMsg::Bank(BankMsg::Send { to_address: info.sender.to_string(), assets: vec![Coin { denom: denom.clone(), amount: withdraw_amount }] })
}
AssetInfo::Token { contract_addr } => {
CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: contract_addr.to_string(),
msg: to_binary(&Cw20ExecuteMsg::Transfer { recipient: info.sender.to_string(), amount: withdraw_amount })?,
funds: vec![],
})
}
};
messages.push(msg);
}
Ok(Response::new()
.add_messages(messages)
.add_attribute("method", "withdraw_liquidity")
.add_attribute("liquidity_burned", amount)
.add_attribute("withdraw_0", withdraw_0)
.add_attribute("withdraw_1", withdraw_1))
}
#[entry_point]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> Result<Binary, ContractError> {
match msg {
QueryMsg::Pool {} => Ok(to_binary(&query_pool(deps)?)?),
QueryMsg::Simulation { offer_asset } => Ok(to_binary(&query_simulation(deps, offer_asset)?)?),
QueryMsg::ReverseSimulation { ask_asset } => Ok(to_binary(&query_reverse_simulation(deps, ask_asset)?)?),
QueryMsg::PairInfo {} => Ok(to_binary(&query_pair_info(deps)?)?),
}
}
fn query_pool(deps: Deps) -> Result<PoolResponse, ContractError> {
let config = CONFIG.load(deps.storage)?;
let pool_state = POOL_STATE.load(deps.storage)?;
Ok(PoolResponse {
assets: [Asset { info: config.asset_infos[0].clone(), amount: pool_state.reserve_0 },
Asset { info: config.asset_infos[1].clone(), amount: pool_state.reserve_1 }],
total_liquidity: pool_state.total_liquidity,
price0_cumulative_last: pool_state.price0_cumulative_last,
price1_cumulative_last: pool_state.price1_cumulative_last,
block_timestamp_last: pool_state.block_timestamp_last,
})
}
fn query_simulation(deps: Deps, offer_asset: Asset) -> Result<SimulationResponse, ContractError> {
let config = CONFIG.load(deps.storage)?;
let pool_state = POOL_STATE.load(deps.storage)?;
let (offer_reserve, ask_reserve) = if offer_asset.info == config.asset_infos[0] {
(pool_state.reserve_0, pool_state.reserve_1)
} else if offer_asset.info == config.asset_infos[1] {
(pool_state.reserve_1, pool_state.reserve_0)
} else { return Err(ContractError::InvalidOfferAsset {}); };
let (return_amount, spread_amount, commission_amount) = compute_swap(offer_asset.amount, offer_reserve, ask_reserve, config.fee_bps)
.map_err(|e| ContractError::Std(StdError::generic_err(e)))?;
Ok(SimulationResponse { return_amount, spread_amount, commission_amount })
}
fn query_reverse_simulation(deps: Deps, ask_asset: Asset) -> Result<ReverseSimulationResponse, ContractError> {
let config = CONFIG.load(deps.storage)?;
let pool_state = POOL_STATE.load(deps.storage)?;
let (offer_reserve, ask_reserve) = if ask_asset.info == config.asset_infos[0] {
(pool_state.reserve_1, pool_state.reserve_0)
} else if ask_asset.info == config.asset_infos[1] {
(pool_state.reserve_0, pool_state.reserve_1)
} else { return Err(ContractError::InvalidOfferAsset {}); };
let (offer_amount, spread_amount, commission_amount) = compute_swap_reverse(ask_asset.amount, offer_reserve, ask_reserve, config.fee_bps)
.map_err(|e| ContractError::Std(StdError::generic_err(e)))?;
Ok(ReverseSimulationResponse { offer_amount, spread_amount, commission_amount })
}
fn query_pair_info(deps: Deps) -> Result<PairInfoResponse, ContractError> {
let config = CONFIG.load(deps.storage)?;
Ok(PairInfoResponse {
asset_infos: config.asset_infos,
contract_addr: deps.api.addr_to_string(&deps.api.addr_canonicalize("")?),
liquidity_token: config.lp_token_addr.to_string(),
})
}
4.7 Pair lib.rs
// contracts/pair/src/lib.rs
pub mod contract;
pub mod error;
pub mod math;
pub mod msg;
pub mod state;
pub use crate::error::ContractError;
5. Part 3: Router 合约
Router 合约处理多跳交换和流动性管理路由。
5.1 Router Cargo.toml
# contracts/router/Cargo.toml
[package]
name = "router"
version.workspace = true
license.workspace = true
edition.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
cosmwasm-std.workspace = true
cw-storage-plus.workspace = true
cw2.workspace = true
serde.workspace = true
schemars.workspace = true
thiserror.workspace = true
cosmwasm-schema.workspace = true
dex-types = { path = "../../packages/dex-types" }
pair = { path = "../pair" }
uint.workspace = true
[dev-dependencies]
cw-multi-test.workspace = true
5.2 Router state.rs
// contracts/router/src/state.rs
use cosmwasm_std::Addr;
use cw_storage_plus::Item;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Config {
pub factory_addr: Addr,
}
pub const CONFIG: Item<Config> = Item::new("config");
5.3 Router msg.rs
// contracts/router/src/msg.rs
use cosmwasm_std::{Decimal, Uint128};
use dex_types::{Asset, AssetInfo, SwapOperation};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg {
pub factory_addr: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum ExecuteMsg {
ExecuteSwapOperations {
operations: Vec<SwapOperation>,
minimum_receive: Option<Uint128>,
to: Option<String>,
},
ExecuteSwapOperation {
operation: SwapOperation,
to: Option<String>,
},
ProvideLiquidity {
pair_contract: String,
assets: [Asset; 2],
slippage_tolerance: Option<Decimal>,
},
WithdrawLiquidity {
pair_contract: String,
amount: Uint128,
min_assets_to_withdraw: Option<[Asset; 2]>,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum QueryMsg {
Config {},
SimulateSwapOperations {
offer_amount: Uint128,
operations: Vec<SwapOperation>,
},
}
5.4 Router error.rs
// contracts/router/src/error.rs
use cosmwasm_std::StdError;
use thiserror::Error;
#[derive(Error, Debug, PartialEq)]
pub enum ContractError {
#[error("{0}")]
Std(#[from] StdError),
#[error("Unauthorized")]
Unauthorized {},
#[error("Insufficient output: {received} < {minimum}")]
InsufficientOutput { received: String, minimum: String },
#[error("Invalid operation")]
InvalidOperation {},
#[error("Empty operations")]
EmptyOperations {},
}
5.5 Router contract.rs
// contracts/router/src/contract.rs
use cosmwasm_std::{
entry_point, to_binary, Binary, Coin, CosmosMsg, Decimal, Deps, DepsMut, Env,
MessageInfo, Response, StdError, Uint128, WasmMsg,
};
use cw2::set_contract_version;
use cw20::Cw20ExecuteMsg;
use dex_types::{Asset, AssetInfo, PairInfo, SwapOperation};
use crate::error::ContractError;
use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg};
use crate::state::{Config, CONFIG};
const CONTRACT_NAME: &str = "msg-dex-router";
const CONTRACT_VERSION: &str = "1.0.0";
#[entry_point]
pub fn instantiate(
deps: DepsMut,
_env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> Result<Response, ContractError> {
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
let config = Config { factory_addr: deps.api.addr_validate(&msg.factory_addr)? };
CONFIG.save(deps.storage, &config)?;
Ok(Response::new().add_attribute("method", "instantiate").add_attribute("factory", msg.factory_addr))
}
#[entry_point]
pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> Result<Response, ContractError> {
match msg {
ExecuteMsg::ExecuteSwapOperations { operations, minimum_receive, to } => {
execute_swap_operations(deps, env, info, operations, minimum_receive, to)
}
ExecuteMsg::ExecuteSwapOperation { operation, to } => {
execute_swap_operation(deps, env, info, operation, to)
}
ExecuteMsg::ProvideLiquidity { pair_contract, assets, slippage_tolerance } => {
execute_provide_liquidity(deps, info, pair_contract, assets, slippage_tolerance)
}
ExecuteMsg::WithdrawLiquidity { pair_contract, amount, min_assets_to_withdraw } => {
execute_withdraw_liquidity(deps, info, pair_contract, amount, min_assets_to_withdraw)
}
}
}
pub fn execute_swap_operations(
deps: DepsMut,
env: Env,
info: MessageInfo,
operations: Vec<SwapOperation>,
minimum_receive: Option<Uint128>,
to: Option<String>,
) -> Result<Response, ContractError> {
if operations.is_empty() { return Err(ContractError::EmptyOperations {}); }
let config = CONFIG.load(deps.storage)?;
let mut messages: Vec<CosmosMsg> = vec![];
let mut prev_asset: Option<Asset> = None;
let pairs: Vec<PairInfo> = deps.querier.query_wasm_smart(
&config.factory_addr,
&dex_types::factory::QueryMsg::Pairs { start_after: None, limit: None },
)?;
for (i, operation) in operations.iter().enumerate() {
let is_last = i == operations.len() - 1;
let to_addr = if is_last {
to.clone().unwrap_or_else(|| info.sender.to_string())
} else {
env.contract.address.to_string()
};
match operation {
SwapOperation::NativeSwap { offer_denom, ask_denom } => {
let pair = pairs.iter().find(|p| {
let has_offer = p.asset_infos.iter().any(|a| match a {
AssetInfo::NativeToken { denom } => denom == offer_denom, _ => false,
});
let has_ask = p.asset_infos.iter().any(|a| match a {
AssetInfo::NativeToken { denom } => denom == ask_denom, _ => false,
});
has_offer && has_ask
}).ok_or_else(|| ContractError::InvalidOperation {})?;
let offer_amount = if i == 0 {
info.funds.iter().find(|c| c.denom == *offer_denom).map(|c| c.amount).unwrap_or(Uint128::zero())
} else if let Some(ref prev) = prev_asset { prev.amount } else { Uint128::zero() };
if offer_amount.is_zero() { return Err(ContractError::InvalidOperation {}); }
let swap_msg = CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: pair.contract_addr.to_string(),
msg: to_binary(&pair::msg::ExecuteMsg::Swap {
offer_asset: Asset { info: AssetInfo::NativeToken { denom: offer_denom.clone() }, amount: offer_amount },
belief_price: None, max_spread: None, to: Some(to_addr),
})?,
funds: if i == 0 { info.funds.clone() } else { vec![] },
});
messages.push(swap_msg);
prev_asset = Some(Asset { info: AssetInfo::NativeToken { denom: ask_denom.clone() }, amount: Uint128::zero() });
}
SwapOperation::CosmWasmSwap { offer_asset_info, ask_asset_info } => {
let pair = pairs.iter().find(|p| {
p.asset_infos.contains(offer_asset_info) && p.asset_infos.contains(ask_asset_info)
}).ok_or_else(|| ContractError::InvalidOperation {})?;
let offer_amount = if i == 0 {
match offer_asset_info {
AssetInfo::NativeToken { denom } => info.funds.iter().find(|c| c.denom == *denom).map(|c| c.amount).unwrap_or(Uint128::zero()),
AssetInfo::Token { .. } => Uint128::zero(),
}
} else if let Some(ref prev) = prev_asset { prev.amount } else { Uint128::zero() };
if offer_amount.is_zero() { return Err(ContractError::InvalidOperation {}); }
let funds = if i == 0 {
match offer_asset_info {
AssetInfo::NativeToken { denom } => vec![Coin { denom: denom.clone(), amount: offer_amount }],
AssetInfo::Token { .. } => vec![],
}
} else { vec![] };
messages.push(CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: pair.contract_addr.to_string(),
msg: to_binary(&pair::msg::ExecuteMsg::Swap {
offer_asset: Asset { info: offer_asset_info.clone(), amount: offer_amount },
belief_price: None, max_spread: None, to: Some(to_addr),
})?,
funds,
}));
prev_asset = Some(Asset { info: ask_asset_info.clone(), amount: Uint128::zero() });
}
}
}
Ok(Response::new()
.add_messages(messages)
.add_attribute("method", "execute_swap_operations")
.add_attribute("operations_count", operations.len().to_string()))
}
pub fn execute_swap_operation(
deps: DepsMut,
_env: Env,
info: MessageInfo,
operation: SwapOperation,
to: Option<String>,
) -> Result<Response, ContractError> {
let config = CONFIG.load(deps.storage)?;
let pairs: Vec<PairInfo> = deps.querier.query_wasm_smart(
&config.factory_addr, &dex_types::factory::QueryMsg::Pairs { start_after: None, limit: None },
)?;
let (pair_addr, offer_asset, funds) = match &operation {
SwapOperation::NativeSwap { offer_denom, ask_denom } => {
let pair = pairs.iter().find(|p| {
p.asset_infos.iter().any(|a| matches!(a, AssetInfo::NativeToken { denom } if denom == offer_denom)) &&
p.asset_infos.iter().any(|a| matches!(a, AssetInfo::NativeToken { denom } if denom == ask_denom))
}).ok_or_else(|| ContractError::InvalidOperation {})?;
let amount = info.funds.iter().find(|c| c.denom == *offer_denom).map(|c| c.amount).unwrap_or(Uint128::zero());
(pair.contract_addr.clone(), Asset { info: AssetInfo::NativeToken { denom: offer_denom.clone() }, amount }, info.funds.clone())
}
SwapOperation::CosmWasmSwap { offer_asset_info, ask_asset_info: _ } => {
let pair = pairs.iter().find(|p| p.asset_infos.contains(offer_asset_info)).ok_or_else(|| ContractError::InvalidOperation {})?;
let amount = info.funds.iter().filter_map(|c| match offer_asset_info {
AssetInfo::NativeToken { denom } if c.denom == *denom => Some(c.amount),
_ => None,
}).sum::<Uint128>();
(pair.contract_addr.clone(), Asset { info: offer_asset_info.clone(), amount }, vec![])
}
};
Ok(Response::new().add_message(CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: pair_addr.to_string(),
msg: to_binary(&pair::msg::ExecuteMsg::Swap {
offer_asset, belief_price: None, max_spread: None, to,
})?,
funds,
})).add_attribute("method", "execute_swap_operation"))
}
pub fn execute_provide_liquidity(
deps: DepsMut,
info: MessageInfo,
pair_contract: String,
assets: [Asset; 2],
slippage_tolerance: Option<Decimal>,
) -> Result<Response, ContractError> {
Ok(Response::new().add_message(CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: deps.api.addr_validate(&pair_contract)?.to_string(),
msg: to_binary(&pair::msg::ExecuteMsg::ProvideLiquidity {
assets, slippage_tolerance, auto_stake: None, receiver: Some(info.sender.to_string()),
})?,
funds: info.funds,
})).add_attribute("method", "provide_liquidity"))
}
pub fn execute_withdraw_liquidity(
deps: DepsMut,
info: MessageInfo,
pair_contract: String,
amount: Uint128,
min_assets_to_withdraw: Option<[Asset; 2]>,
) -> Result<Response, ContractError> {
Ok(Response::new().add_message(CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: deps.api.addr_validate(&pair_contract)?.to_string(),
msg: to_binary(&pair::msg::ExecuteMsg::WithdrawLiquidity { amount, min_assets_to_withdraw })?,
funds: vec![],
})).add_attribute("method", "withdraw_liquidity"))
}
#[entry_point]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> Result<Binary, ContractError> {
match msg {
QueryMsg::Config {} => Ok(to_binary(&CONFIG.load(deps.storage)?)?),
QueryMsg::SimulateSwapOperations { .. } => Ok(to_binary("{}")?),
}
}
5.6 Router lib.rs
// contracts/router/src/lib.rs
pub mod contract;
pub mod error;
pub mod msg;
pub mod state;
pub use crate::error::ContractError;
6. Part 4: LP Token 实现
LP 代币使用标准 CW20 实现(cw20-base),内嵌在 Pair 合约中。Pair 合约在实例化时创建一个 CW20 代币作为该池的 LP 代币。
LP 代币规格
| 属性 | 值 |
|---|---|
| 名称格式 | MSG DEX LP {asset0}-{asset1} |
| 符号格式 | MLP-{A3}-{B3}(如 MLP-UMSG-USDC) |
| 精度 | 18 位小数(匹配 MSG Chain 标准) |
| 铸造权限 | 仅 Pair 合约 |
| 销毁机制 | 持有者或授权者可销毁 |
LP 代币生命周期
1. Pair 合约实例化
└── 创建 CW20 代币(设置 Pair 合约为 minter)
2. 用户提供流动性
└── Pair 合约调用 Mint(recipient, amount) 铸造 LP 代币
3. 首次流动性
└── 额外销毁 MINIMUM_LIQUIDITY=1000 个 LP 代币(永久锁定)
4. 用户移除流动性
└── 用户先 IncreaseAllowance(Pair, amount)
└── Pair 合约调用 BurnFrom(owner, amount) 销毁 LP 代币
└── 返回对应资产给用户
核心交互代码
// 铸造 LP 代币
CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: config.lp_token_addr.to_string(),
msg: to_binary(&Cw20ExecuteMsg::Mint {
recipient: user_addr,
amount: liquidity,
})?,
funds: vec![],
})
// 销毁 LP 代币(移除流动性时)
CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: config.lp_token_addr.to_string(),
msg: to_binary(&Cw20ExecuteMsg::BurnFrom {
owner: user_addr,
amount: liquidity_to_burn,
})?,
funds: vec![],
})
// 用户授权(前端)
CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: lp_token_addr,
msg: to_binary(&Cw20ExecuteMsg::IncreaseAllowance {
spender: pair_addr,
amount: lp_amount,
})?,
funds: vec![],
})
7. Part 5: 单元测试与集成测试
7.1 Factory 合约集成测试
// contracts/factory/tests/integration.rs
use cosmwasm_std::{Addr, Empty, Uint128};
use cw_multi_test::{App, Contract, ContractWrapper, Executor};
use dex_types::{Asset, AssetInfo};
use factory::contract::{execute, instantiate, query, reply};
use factory::msg::{
ConfigResponse, ExecuteMsg, InstantiateMsg, PairResponse, PairsResponse, QueryMsg,
};
fn mock_factory() -> Box<dyn Contract<Empty>> {
Box::new(ContractWrapper::new(
|d, e, i, m: ExecuteMsg| execute(d, e, i, m),
|d, e, i, m: InstantiateMsg| instantiate(d, e, i, m),
|d, e, m: QueryMsg| query(d, e, m),
).with_reply(|d, e, m| reply(d, e, m)))
}
fn mock_pair() -> Box<dyn Contract<Empty>> {
Box::new(ContractWrapper::new(
|d, e, i, m: pair::msg::ExecuteMsg| pair::contract::execute(d, e, i, m),
|d, e, i, m: pair::msg::InstantiateMsg| pair::contract::instantiate(d, e, i, m),
|d, e, m: pair::msg::QueryMsg| pair::contract::query(d, e, m),
).with_reply(|d, e, m| pair::contract::reply(d, e, m)))
}
fn mock_cw20() -> Box<dyn Contract<Empty>> {
Box::new(ContractWrapper::new(
|d, e, i, m: cw20_base::msg::ExecuteMsg| cw20_base::contract::execute(d, e, i, m),
|d, e, i, m: cw20_base::msg::InstantiateMsg| cw20_base::contract::instantiate(d, e, i, m),
|d, e, m: cw20_base::msg::QueryMsg| cw20_base::contract::query(d, e, m),
))
}
#[test]
fn test_factory_instantiate_and_query() {
let mut app = App::default();
let factory_id = app.store_code(mock_factory());
let pair_id = app.store_code(mock_pair());
let _cw20_id = app.store_code(mock_cw20());
let factory = app.instantiate_contract(
factory_id, Addr::unchecked("owner"),
&InstantiateMsg { owner: "owner".to_string(), fee_to: None, pair_code_id: pair_id, fee_bps: Some(30) },
&[], "MSG-DEX-Factory",
).unwrap();
let config: ConfigResponse = app.wrap().query_wasm_smart(&factory, &QueryMsg::Config {}).unwrap();
assert_eq!(config.owner, "owner");
assert_eq!(config.pair_code_id, pair_id);
assert_eq!(config.fee_bps, 30);
let pairs: PairsResponse = app.wrap().query_wasm_smart(&factory, &QueryMsg::Pairs { start_after: None, limit: None }).unwrap();
assert!(pairs.pairs.is_empty());
}
#[test]
fn test_factory_create_and_query_pair() {
let mut app = App::default();
let factory_id = app.store_code(mock_factory());
let pair_id = app.store_code(mock_pair());
let _cw20_id = app.store_code(mock_cw20());
let factory = app.instantiate_contract(
factory_id, Addr::unchecked("owner"),
&InstantiateMsg { owner: "owner".to_string(), fee_to: None, pair_code_id: pair_id, fee_bps: Some(30) },
&[], "MSG-DEX-Factory",
).unwrap();
let native = AssetInfo::NativeToken { denom: "umsg".to_string() };
let token = AssetInfo::Token { contract_addr: Addr::unchecked("msg_token_cw20") };
app.execute_contract(
Addr::unchecked("owner"), factory.clone(),
&ExecuteMsg::CreatePair { asset_infos: [native.clone(), token.clone()], init_params: None },
&[],
).unwrap();
let pair: PairResponse = app.wrap().query_wasm_smart(
&factory, &QueryMsg::Pair { asset_infos: [native.clone(), token.clone()] },
).unwrap();
assert_eq!(pair.asset_infos[0], native);
assert_eq!(pair.asset_infos[1], token);
assert!(!pair.contract_addr.is_empty());
}
#[test]
fn test_factory_duplicate_pair_fails() {
let mut app = App::default();
let factory_id = app.store_code(mock_factory());
let pair_id = app.store_code(mock_pair());
let _cw20_id = app.store_code(mock_cw20());
let factory = app.instantiate_contract(
factory_id, Addr::unchecked("owner"),
&InstantiateMsg { owner: "owner".to_string(), fee_to: None, pair_code_id: pair_id, fee_bps: Some(30) },
&[], "MSG-DEX-Factory",
).unwrap();
let a = AssetInfo::NativeToken { denom: "umsg".to_string() };
let b = AssetInfo::Token { contract_addr: Addr::unchecked("msg_token_cw20") };
app.execute_contract(Addr::unchecked("owner"), factory.clone(),
&ExecuteMsg::CreatePair { asset_infos: [a.clone(), b.clone()], init_params: None }, &[],
).unwrap();
let err = app.execute_contract(Addr::unchecked("owner"), factory.clone(),
&ExecuteMsg::CreatePair { asset_infos: [a, b], init_params: None }, &[],
).unwrap_err();
assert!(err.to_string().contains("Pair already exists"));
}
#[test]
fn test_factory_unauthorized_update() {
let mut app = App::default();
let factory_id = app.store_code(mock_factory());
let pair_id = app.store_code(mock_pair());
let _cw20_id = app.store_code(mock_cw20());
let factory = app.instantiate_contract(
factory_id, Addr::unchecked("owner"),
&InstantiateMsg { owner: "owner".to_string(), fee_to: None, pair_code_id: pair_id, fee_bps: Some(30) },
&[], "MSG-DEX-Factory",
).unwrap();
let err = app.execute_contract(Addr::unchecked("attacker"), factory,
&ExecuteMsg::UpdateConfig { owner: None, fee_to: None, fee_bps: Some(50), pair_code_id: None }, &[],
).unwrap_err();
assert!(err.to_string().contains("Unauthorized"));
}
7.2 Pair/AMM 合约集成测试
// contracts/pair/tests/integration.rs
use cosmwasm_std::{Addr, Coin, Empty, Uint128};
use cw_multi_test::{App, Contract, ContractWrapper, Executor};
use dex_types::{Asset, AssetInfo};
use pair::contract::{execute, instantiate, query, reply};
use pair::msg::{
ExecuteMsg, InstantiateMsg, PoolResponse, QueryMsg, SimulationResponse,
};
fn mock_pair() -> Box<dyn Contract<Empty>> {
Box::new(ContractWrapper::new(
|d, e, i, m: ExecuteMsg| execute(d, e, i, m),
|d, e, i, m: InstantiateMsg| instantiate(d, e, i, m),
|d, e, m: QueryMsg| query(d, e, m),
).with_reply(|d, e, m| reply(d, e, m)))
}
fn mock_cw20() -> Box<dyn Contract<Empty>> {
Box::new(ContractWrapper::new(
|d, e, i, m: cw20_base::msg::ExecuteMsg| cw20_base::contract::execute(d, e, i, m),
|d, e, i, m: cw20_base::msg::InstantiateMsg| cw20_base::contract::instantiate(d, e, i, m),
|d, e, m: cw20_base::msg::QueryMsg| cw20_base::contract::query(d, e, m),
))
}
fn setup_app() -> (App, Addr) {
let mut app = App::new(|router, _, storage| {
router.bank.init_balance(storage, &Addr::unchecked("user"),
vec![Coin { denom: "umsg".to_string(), amount: Uint128::new(1_000_000_000_000) }],
).unwrap();
});
let pair_id = app.store_code(mock_pair());
let cw20_id = app.store_code(mock_cw20());
let pair = app.instantiate_contract(
pair_id, Addr::unchecked("factory"),
&InstantiateMsg {
asset_infos: [
AssetInfo::NativeToken { denom: "umsg".to_string() },
AssetInfo::NativeToken { denom: "uusdc".to_string() },
],
factory_addr: "factory".to_string(),
lp_token_code_id: cw20_id,
fee_bps: 30,
},
&[], "MSG-DEX-Pair",
).unwrap();
(app, pair)
}
#[test]
fn test_initial_pool_state() {
let (app, pair) = setup_app();
let pool: PoolResponse = app.wrap().query_wasm_smart(&pair, &QueryMsg::Pool {}).unwrap();
assert_eq!(pool.assets[0].amount, Uint128::zero());
assert_eq!(pool.assets[1].amount, Uint128::zero());
assert_eq!(pool.total_liquidity, Uint128::zero());
}
#[test]
fn test_add_initial_liquidity() {
let mut app = App::new(|router, _, storage| {
router.bank.init_balance(storage, &Addr::unchecked("user"),
vec![
Coin { denom: "umsg".to_string(), amount: Uint128::new(1_000_000_000_000) },
Coin { denom: "uusdc".to_string(), amount: Uint128::new(1_000_000_000_000) },
],
).unwrap();
});
let pair_id = app.store_code(mock_pair());
let cw20_id = app.store_code(mock_cw20());
let pair = app.instantiate_contract(
pair_id, Addr::unchecked("factory"),
&InstantiateMsg {
asset_infos: [
AssetInfo::NativeToken { denom: "umsg".to_string() },
AssetInfo::NativeToken { denom: "uusdc".to_string() },
],
factory_addr: "factory".to_string(),
lp_token_code_id: cw20_id,
fee_bps: 30,
},
&[], "MSG-DEX-Pair",
).unwrap();
app.execute_contract(
Addr::unchecked("user"), pair.clone(),
&ExecuteMsg::ProvideLiquidity {
assets: [
Asset { info: AssetInfo::NativeToken { denom: "umsg".to_string() }, amount: Uint128::new(100_000_000) },
Asset { info: AssetInfo::NativeToken { denom: "uusdc".to_string() }, amount: Uint128::new(200_000_000) },
],
slippage_tolerance: None, auto_stake: None, receiver: None,
},
&[
Coin { denom: "umsg".to_string(), amount: Uint128::new(100_000_000) },
Coin { denom: "uusdc".to_string(), amount: Uint128::new(200_000_000) },
],
).unwrap();
let pool: PoolResponse = app.wrap().query_wasm_smart(&pair, &QueryMsg::Pool {}).unwrap();
assert_eq!(pool.assets[0].amount, Uint128::new(100_000_000));
assert_eq!(pool.assets[1].amount, Uint128::new(200_000_000));
assert!(pool.total_liquidity > Uint128::zero());
}
#[test]
fn test_swap_basic() {
let mut app = App::new(|router, _, storage| {
router.bank.init_balance(storage, &Addr::unchecked("user"),
vec![
Coin { denom: "umsg".to_string(), amount: Uint128::new(1_000_000_000_000) },
Coin { denom: "uusdc".to_string(), amount: Uint128::new(1_000_000_000_000) },
],
).unwrap();
});
let pair_id = app.store_code(mock_pair());
let cw20_id = app.store_code(mock_cw20());
let pair = app.instantiate_contract(
pair_id, Addr::unchecked("factory"),
&InstantiateMsg {
asset_infos: [
AssetInfo::NativeToken { denom: "umsg".to_string() },
AssetInfo::NativeToken { denom: "uusdc".to_string() },
],
factory_addr: "factory".to_string(),
lp_token_code_id: cw20_id,
fee_bps: 30,
},
&[], "MSG-DEX-Pair",
).unwrap();
// 添加初始流动性
app.execute_contract(
Addr::unchecked("user"), pair.clone(),
&ExecuteMsg::ProvideLiquidity {
assets: [
Asset { info: AssetInfo::NativeToken { denom: "umsg".to_string() }, amount: Uint128::new(1_000_000_000) },
Asset { info: AssetInfo::NativeToken { denom: "uusdc".to_string() }, amount: Uint128::new(1_000_000_000) },
],
slippage_tolerance: None, auto_stake: None, receiver: None,
},
&[
Coin { denom: "umsg".to_string(), amount: Uint128::new(1_000_000_000) },
Coin { denom: "uusdc".to_string(), amount: Uint128::new(1_000_000_000) },
],
).unwrap();
// 模拟交换
let sim: SimulationResponse = app.wrap().query_wasm_smart(&pair, &QueryMsg::Simulation {
offer_asset: Asset { info: AssetInfo::NativeToken { denom: "umsg".to_string() }, amount: Uint128::new(1_000_000) },
}).unwrap();
assert!(!sim.return_amount.is_zero());
// 执行交换
app.execute_contract(
Addr::unchecked("user"), pair.clone(),
&ExecuteMsg::Swap {
offer_asset: Asset { info: AssetInfo::NativeToken { denom: "umsg".to_string() }, amount: Uint128::new(1_000_000) },
belief_price: None, max_spread: None, to: None,
},
&[Coin { denom: "umsg".to_string(), amount: Uint128::new(1_000_000) }],
).unwrap();
let pool: PoolResponse = app.wrap().query_wasm_smart(&pair, &QueryMsg::Pool {}).unwrap();
assert_eq!(pool.assets[0].amount, Uint128::new(1_001_000_000));
}
#[test]
fn test_swap_slippage_protection() {
let mut app = App::new(|router, _, storage| {
router.bank.init_balance(storage, &Addr::unchecked("user"),
vec![
Coin { denom: "umsg".to_string(), amount: Uint128::new(1_000_000_000_000) },
Coin { denom: "uusdc".to_string(), amount: Uint128::new(1_000_000_000_000) },
],
).unwrap();
});
let pair_id = app.store_code(mock_pair());
let cw20_id = app.store_code(mock_cw20());
let pair = app.instantiate_contract(
pair_id, Addr::unchecked("factory"),
&InstantiateMsg {
asset_infos: [
AssetInfo::NativeToken { denom: "umsg".to_string() },
AssetInfo::NativeToken { denom: "uusdc".to_string() },
],
factory_addr: "factory".to_string(),
lp_token_code_id: cw20_id,
fee_bps: 30,
},
&[], "MSG-DEX-Pair",
).unwrap();
// 添加流动性 1:1
app.execute_contract(
Addr::unchecked("user"), pair.clone(),
&ExecuteMsg::ProvideLiquidity {
assets: [
Asset { info: AssetInfo::NativeToken { denom: "umsg".to_string() }, amount: Uint128::new(1_000_000_000) },
Asset { info: AssetInfo::NativeToken { denom: "uusdc".to_string() }, amount: Uint128::new(1_000_000_000) },
],
slippage_tolerance: None, auto_stake: None, receiver: None,
},
&[
Coin { denom: "umsg".to_string(), amount: Uint128::new(1_000_000_000) },
Coin { denom: "uusdc".to_string(), amount: Uint128::new(1_000_000_000) },
],
).unwrap();
// 使用不合理的 belief_price(期望 1 umsg = 200 uusdc,实际约 1)
let err = app.execute_contract(
Addr::unchecked("user"), pair.clone(),
&ExecuteMsg::Swap {
offer_asset: Asset { info: AssetInfo::NativeToken { denom: "umsg".to_string() }, amount: Uint128::new(1_000_000) },
belief_price: Some(cosmwasm_std::Decimal::from_ratio(Uint128::new(200), Uint128::new(1))),
max_spread: None, to: None,
},
&[Coin { denom: "umsg".to_string(), amount: Uint128::new(1_000_000) }],
).unwrap_err();
assert!(err.to_string().contains("Slippage"));
}
#[test]
fn test_k_invariant_increases_with_fees() {
let mut app = App::new(|router, _, storage| {
router.bank.init_balance(storage, &Addr::unchecked("user"),
vec![
Coin { denom: "umsg".to_string(), amount: Uint128::new(1_000_000_000_000) },
Coin { denom: "uusdc".to_string(), amount: Uint128::new(1_000_000_000_000) },
],
).unwrap();
});
let pair_id = app.store_code(mock_pair());
let cw20_id = app.store_code(mock_cw20());
let pair = app.instantiate_contract(
pair_id, Addr::unchecked("factory"),
&InstantiateMsg {
asset_infos: [
AssetInfo::NativeToken { denom: "umsg".to_string() },
AssetInfo::NativeToken { denom: "uusdc".to_string() },
],
factory_addr: "factory".to_string(),
lp_token_code_id: cw20_id,
fee_bps: 30,
},
&[], "MSG-DEX-Pair",
).unwrap();
app.execute_contract(
Addr::unchecked("user"), pair.clone(),
&ExecuteMsg::ProvideLiquidity {
assets: [
Asset { info: AssetInfo::NativeToken { denom: "umsg".to_string() }, amount: Uint128::new(1_000_000_000) },
Asset { info: AssetInfo::NativeToken { denom: "uusdc".to_string() }, amount: Uint128::new(1_000_000_000) },
],
slippage_tolerance: None, auto_stake: None, receiver: None,
},
&[
Coin { denom: "umsg".to_string(), amount: Uint128::new(1_000_000_000) },
Coin { denom: "uusdc".to_string(), amount: Uint128::new(1_000_000_000) },
],
).unwrap();
let pool_before: PoolResponse = app.wrap().query_wasm_smart(&pair, &QueryMsg::Pool {}).unwrap();
let initial_k = pool_before.assets[0].amount * pool_before.assets[1].amount;
for i in 0..3 {
let amount = Uint128::new(10_000_000 * (i + 1));
app.execute_contract(
Addr::unchecked("user"), pair.clone(),
&ExecuteMsg::Swap {
offer_asset: Asset { info: AssetInfo::NativeToken { denom: "umsg".to_string() }, amount },
belief_price: None, max_spread: None, to: None,
},
&[Coin { denom: "umsg".to_string(), amount }],
).unwrap();
}
let pool_after: PoolResponse = app.wrap().query_wasm_smart(&pair, &QueryMsg::Pool {}).unwrap();
let final_k = pool_after.assets[0].amount * pool_after.assets[1].amount;
assert!(final_k > initial_k, "k should increase due to fees");
}
#[test]
fn test_reverse_simulation() {
let (app, pair) = setup_app();
// 需要先有流动性,简化起见假设已有
let _sim = app.wrap().query_wasm_smart::<pair::msg::ReverseSimulationResponse>(
&pair, &QueryMsg::ReverseSimulation {
ask_asset: Asset { info: AssetInfo::NativeToken { denom: "umsg".to_string() }, amount: Uint128::new(100) },
},
);
}
#[test]
fn test_query_pair_info() {
let (app, pair) = setup_app();
let info: pair::msg::PairInfoResponse = app.wrap().query_wasm_smart(
&pair, &pair::msg::QueryMsg::PairInfo {},
).unwrap();
assert_eq!(info.asset_infos.len(), 2);
}
7.3 运行测试
# 运行所有测试
cargo test --workspace
# 运行特定合约测试
cargo test -p factory
cargo test -p pair
cargo test -p router
# 运行数学库测试
cargo test -p pair math::tests
# 生成测试覆盖率
cargo tarpaulin --workspace --ignore-tests
7.4 Schema 生成
// contracts/factory/src/bin/schema.rs
use cosmwasm_schema::write_api;
use factory::msg::{ExecuteMsg, InstantiateMsg, QueryMsg};
fn main() {
write_api! {
instantiate: InstantiateMsg,
execute: ExecuteMsg,
query: QueryMsg,
}
}
# 在项目根目录
cargo schema
# 生成 schema 到 contracts/*/schema/ 目录
8. Part 6: 前端实现 (React + TypeScript + Tailwind)
8.1 项目配置
{
"name": "msg-dex-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@cosmjs/cosmwasm-stargate": "^0.32.0",
"@cosmjs/stargate": "^0.32.0",
"@cosmjs/proto-signing": "^0.32.0",
"@cosmjs/encoding": "^0.32.0",
"@keplr-wallet/types": "^0.12.0",
"@keplr-wallet/cosmos": "^0.12.0",
"decimal.js": "^10.4.3",
"big.js": "^6.2.1",
"react-router-dom": "^6.20.0",
"recharts": "^2.10.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.3.2",
"vite": "^5.0.0"
}
}
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
dex: {
bg: "#0f111a",
card: "#1a1c2b",
border: "#2a2d3e",
text: "#ffffff",
"text-secondary": "#8a8ca0",
accent: "#4f8cff",
green: "#22c55e",
red: "#ef4444",
},
},
},
},
plugins: [],
};
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
define: { "process.env": {} },
});
8.2 类型定义
// src/types/index.ts
export interface AssetInfo {
native_token?: { denom: string };
token?: { contract_addr: string };
}
export interface Asset {
info: AssetInfo;
amount: string;
}
export interface PairInfo {
asset_infos: [AssetInfo, AssetInfo];
contract_addr: string;
liquidity_token: string;
}
export interface PoolResponse {
assets: [Asset, Asset];
total_liquidity: string;
}
export interface SimulationResponse {
return_amount: string;
spread_amount: string;
commission_amount: string;
}
export interface ChainInfo {
chainId: string;
chainName: string;
rpc: string;
rest: string;
bech32Config: {
bech32PrefixAccAddr: string;
bech32PrefixAccPub: string;
bech32PrefixValAddr: string;
bech32PrefixValPub: string;
bech32PrefixConsAddr: string;
bech32PrefixConsPub: string;
};
currencies: { coinDenom: string; coinMinimalDenom: string; coinDecimals: number; coinGeckoId?: string }[];
feeCurrencies: { coinDenom: string; coinMinimalDenom: string; coinDecimals: number; gasPriceStep: { low: number; average: number; high: number } }[];
stakeCurrency: { coinDenom: string; coinMinimalDenom: string; coinDecimals: number };
bip44: { coinType: number };
}
export const MSG_CHAIN: ChainInfo = {
chainId: "msg-chain-1",
chainName: "MSG Chain",
rpc: "https://rpc.msgchain.org",
rest: "https://api.msgchain.org",
bech32Config: {
bech32PrefixAccAddr: "msg", bech32PrefixAccPub: "msgpub",
bech32PrefixValAddr: "msgvaloper", bech32PrefixValPub: "msgvaloperpub",
bech32PrefixConsAddr: "msgvalcons", bech32PrefixConsPub: "msgvalconspub",
},
currencies: [{ coinDenom: "MSG", coinMinimalDenom: "umsg", coinDecimals: 18 }],
feeCurrencies: [{ coinDenom: "MSG", coinMinimalDenom: "umsg", coinDecimals: 18, gasPriceStep: { low: 1_000_000_000, average: 1_000_000_000, high: 1_000_000_000 } }],
stakeCurrency: { coinDenom: "MSG", coinMinimalDenom: "umsg", coinDecimals: 18 },
bip44: { coinType: 118 },
};
8.3 合约客户端
// src/utils/contracts.ts
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { Coin, StdFee } from "@cosmjs/stargate";
export class DexClient {
private client: SigningCosmWasmClient;
private factory: string;
private sender: string;
constructor(client: SigningCosmWasmClient, factory: string, sender: string) {
this.client = client;
this.factory = factory;
this.sender = sender;
}
async getConfig(): Promise<any> {
return this.client.queryContractSmart(this.factory, { config: {} });
}
async getPair(assets: [any, any]): Promise<any> {
return this.client.queryContractSmart(this.factory, { pair: { asset_infos: assets } });
}
async getPairs(limit = 30): Promise<any> {
return this.client.queryContractSmart(this.factory, { pairs: { start_after: null, limit } });
}
async getPool(pair: string): Promise<any> {
return this.client.queryContractSmart(pair, { pool: {} });
}
async simulateSwap(pair: string, asset: any): Promise<any> {
return this.client.queryContractSmart(pair, { simulation: { offer_asset: asset } });
}
async simulateReverse(pair: string, asset: any): Promise<any> {
return this.client.queryContractSmart(pair, { reverse_simulation: { ask_asset: asset } });
}
async createPair(assets: [any, any], fee?: StdFee): Promise<string> {
const tx = await this.client.execute(this.sender, this.factory,
{ create_pair: { asset_infos: assets, init_params: null } },
fee ?? "auto", "Create Pair");
return tx.transactionHash;
}
async swap(pair: string, offerAsset: any, funds?: Coin[], to?: string, fee?: StdFee): Promise<string> {
const tx = await this.client.execute(this.sender, pair,
{ swap: { offer_asset: offerAsset, belief_price: null, max_spread: null, to: to ?? null } },
fee ?? "auto", "Swap", funds);
return tx.transactionHash;
}
async addLiquidity(pair: string, assets: [any, any], funds?: Coin[], fee?: StdFee): Promise<string> {
const tx = await this.client.execute(this.sender, pair,
{ provide_liquidity: { assets, slippage_tolerance: null, auto_stake: null, receiver: null } },
fee ?? "auto", "Add Liquidity", funds);
return tx.transactionHash;
}
async removeLiquidity(pair: string, amount: string, minAssets?: [any, any], fee?: StdFee): Promise<string> {
const tx = await this.client.execute(this.sender, pair,
{ withdraw_liquidity: { amount, min_assets_to_withdraw: minAssets ?? null } },
fee ?? "auto", "Remove Liquidity");
return tx.transactionHash;
}
}
8.4 钱包连接 Hook
// src/hooks/useWallet.ts
import { useState, useCallback, useEffect } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { GasPrice } from "@cosmjs/stargate";
import { MSG_CHAIN } from "../types";
declare global {
interface Window { keplr?: any; getOfflineSigner?: any; }
}
export function useWallet() {
const [address, setAddress] = useState<string>("");
const [client, setClient] = useState<SigningCosmWasmClient | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const connect = useCallback(async () => {
setIsConnecting(true);
setError(null);
try {
if (!window.keplr) {
throw new Error("Please install Keplr wallet");
}
await window.keplr.experimentalSuggestChain(MSG_CHAIN);
await window.keplr.enable(MSG_CHAIN.chainId);
const offlineSigner = window.keplr.getOfflineSigner(MSG_CHAIN.chainId);
const accounts = await offlineSigner.getAccounts();
const addr = accounts[0].address;
const c = await SigningCosmWasmClient.connectWithSigner(
MSG_CHAIN.rpc,
offlineSigner,
{ gasPrice: GasPrice.fromString("1000000000attoMSG") }
);
setAddress(addr);
setClient(c);
} catch (e: any) {
setError(e.message || "Connection failed");
} finally {
setIsConnecting(false);
}
}, []);
const disconnect = useCallback(() => {
setAddress("");
setClient(null);
}, []);
return { address, client, isConnecting, error, connect, disconnect };
}
8.5 Swap 页面
// src/pages/Swap.tsx
import React, { useState, useEffect, useCallback } from "react";
import { useWallet } from "../hooks/useWallet";
import { DexClient } from "../utils/contracts";
import { AssetInfo, SimulationResponse, PoolResponse } from "../types";
import { Coin } from "@cosmjs/stargate";
const FACTORY_ADDR = "msg1factory..."; // 实际部署后更新
const PAIR_ADDR = "msg1pair..."; // 实际部署后更新
export default function Swap() {
const { address, client, connect, isConnecting, error: walletError } = useWallet();
const [fromToken, setFromToken] = useState("umsg");
const [toToken, setToToken] = useState("uusdc");
const [fromAmount, setFromAmount] = useState("");
const [toAmount, setToAmount] = useState("");
const [slippage, setSlippage] = useState(0.5);
const [isSwapping, setIsSwapping] = useState(false);
const [price, setPrice] = useState<string>("-");
const [priceImpact, setPriceImpact] = useState<string>("-");
const [error, setError] = useState<string | null>(null);
const [txHash, setTxHash] = useState<string | null>(null);
const getOfferAsset = (): any => ({
info: { native_token: { denom: fromToken } },
amount: (parseFloat(fromAmount) * 1e18).toString(),
});
const simulate = useCallback(async () => {
if (!client || !fromAmount || parseFloat(fromAmount) === 0) return;
try {
const dex = new DexClient(client, FACTORY_ADDR, address!);
const sim: SimulationResponse = await dex.simulateSwap(PAIR_ADDR, getOfferAsset());
const returnAmt = parseFloat(sim.return_amount) / 1e18;
setToAmount(returnAmt.toFixed(6));
setPrice(returnAmt > 0 ? (parseFloat(fromAmount) / returnAmt).toFixed(6) : "-");
const pool: PoolResponse = await dex.getPool(PAIR_ADDR);
const reserveIn = parseFloat(pool.assets[0].amount) / 1e18;
const impact = (parseFloat(fromAmount) / (reserveIn + parseFloat(fromAmount))) * 100;
setPriceImpact(impact.toFixed(2) + "%");
} catch (e: any) {
console.error("Simulation error:", e);
}
}, [client, fromAmount, fromToken, address]);
useEffect(() => { simulate(); }, [simulate]);
const handleSwap = async () => {
if (!client || !address) return;
setIsSwapping(true);
setError(null);
setTxHash(null);
try {
const dex = new DexClient(client, FACTORY_ADDR, address);
const offerAmount = (parseFloat(fromAmount) * 1e18).toFixed(0);
const funds: Coin[] = [{ denom: fromToken, amount: offerAmount }];
const hash = await dex.swap(PAIR_ADDR, getOfferAsset(), funds);
setTxHash(hash);
setFromAmount("");
setToAmount("");
} catch (e: any) {
setError(e.message || "Swap failed");
} finally {
setIsSwapping(false);
}
};
const switchTokens = () => {
setFromToken(toToken);
setToToken(fromToken);
setFromAmount(toAmount);
setToAmount(fromAmount);
};
if (!address) {
return (
<div className="flex items-center justify-center min-h-[80vh]">
<div className="bg-dex-card p-8 rounded-2xl border border-dex-border w-full max-w-md text-center">
<h2 className="text-2xl font-bold text-white mb-4">Connect Wallet</h2>
<p className="text-dex-text-secondary mb-6">Connect your Keplr wallet to start swapping on MSG Chain</p>
<button onClick={connect} disabled={isConnecting}
className="bg-dex-accent hover:bg-blue-600 text-white font-semibold py-3 px-8 rounded-xl transition w-full">
{isConnecting ? "Connecting..." : "Connect Keplr"}
</button>
{walletError && <p className="text-red-400 mt-4 text-sm">{walletError}</p>}
</div>
</div>
);
}
return (
<div className="flex items-center justify-center min-h-[80vh]">
<div className="bg-dex-card p-6 rounded-2xl border border-dex-border w-full max-w-md">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">Swap</h2>
<span className="text-dex-text-secondary text-sm truncate max-w-[200px]">{address}</span>
</div>
{/* From Token */}
<div className="bg-dex-bg rounded-xl p-4 mb-2">
<div className="flex justify-between items-center mb-2">
<label className="text-dex-text-secondary text-sm">From</label>
<span className="text-dex-text-secondary text-xs">Balance: -</span>
</div>
<div className="flex items-center gap-3">
<input type="number" value={fromAmount} onChange={e => setFromAmount(e.target.value)}
placeholder="0.0" className="bg-transparent text-2xl text-white font-semibold outline-none flex-1" />
<div className="flex items-center gap-2 bg-dex-bg border border-dex-border rounded-lg px-3 py-2">
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-blue-500 to-purple-500" />
<span className="text-white font-medium">{fromToken.toUpperCase()}</span>
</div>
</div>
</div>
{/* Switch button */}
<div className="flex justify-center -my-3 relative z-10">
<button onClick={switchTokens}
className="bg-dex-card border-4 border-dex-bg rounded-xl p-2 hover:bg-dex-border transition">
<svg className="w-5 h-5 text-dex-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
</button>
</div>
{/* To Token */}
<div className="bg-dex-bg rounded-xl p-4 mb-4">
<div className="flex justify-between items-center mb-2">
<label className="text-dex-text-secondary text-sm">To</label>
<span className="text-dex-text-secondary text-xs">Balance: -</span>
</div>
<div className="flex items-center gap-3">
<input type="number" value={toAmount} readOnly placeholder="0.0"
className="bg-transparent text-2xl text-white font-semibold outline-none flex-1" />
<div className="flex items-center gap-2 bg-dex-bg border border-dex-border rounded-lg px-3 py-2">
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-green-500 to-teal-500" />
<span className="text-white font-medium">{toToken.toUpperCase()}</span>
</div>
</div>
</div>
{/* Price info */}
{price !== "-" && (
<div className="bg-dex-bg rounded-xl p-3 mb-4 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-dex-text-secondary">Price</span>
<span className="text-white">1 {fromToken.toUpperCase()} = {price} {toToken.toUpperCase()}</span>
</div>
<div className="flex justify-between">
<span className="text-dex-text-secondary">Price Impact</span>
<span className={parseFloat(priceImpact) > 5 ? "text-red-400" : "text-yellow-400"}>{priceImpact}</span>
</div>
<div className="flex justify-between">
<span className="text-dex-text-secondary">Slippage</span>
<span className="text-white">{slippage}%</span>
</div>
</div>
)}
{error && <p className="text-red-400 text-sm mb-3">{error}</p>}
{txHash && (
<p className="text-green-400 text-sm mb-3 break-all">
Tx: <a href={`https://explorer.msgchain.org/tx/${txHash}`} target="_blank" rel="noreferrer" className="underline">{txHash}</a>
</p>
)}
<button onClick={handleSwap} disabled={isSwapping || !fromAmount || parseFloat(fromAmount) <= 0}
className="w-full bg-gradient-to-r from-dex-accent to-blue-600 text-white font-bold py-4 rounded-xl hover:opacity-90 transition disabled:opacity-40">
{isSwapping ? "Swapping..." : "Swap"}
</button>
</div>
</div>
);
}
8.6 流动性页面
// src/pages/Liquidity.tsx
import React, { useState, useEffect } from "react";
import { useWallet } from "../hooks/useWallet";
import { DexClient } from "../utils/contracts";
import { PoolResponse } from "../types";
import { Coin } from "@cosmjs/stargate";
const FACTORY_ADDR = "msg1factory...";
const PAIR_ADDR = "msg1pair...";
export default function Liquidity() {
const { address, client, connect } = useWallet();
const [mode, setMode] = useState<"add" | "remove">("add");
const [amount0, setAmount0] = useState("");
const [amount1, setAmount1] = useState("");
const [removePercent, setRemovePercent] = useState(25);
const [isSubmitting, setIsSubmitting] = useState(false);
const [pool, setPool] = useState<PoolResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [txHash, setTxHash] = useState<string | null>(null);
useEffect(() => {
if (!client || !address) return;
const dex = new DexClient(client, FACTORY_ADDR, address);
dex.getPool(PAIR_ADDR).then(setPool).catch(console.error);
}, [client, address]);
const handleAddLiquidity = async () => {
if (!client || !address) return;
setIsSubmitting(true);
setError(null);
setTxHash(null);
try {
const dex = new DexClient(client, FACTORY_ADDR, address);
const amt0 = (parseFloat(amount0) * 1e18).toFixed(0);
const amt1 = (parseFloat(amount1) * 1e18).toFixed(0);
const assets: [any, any] = [
{ info: { native_token: { denom: "umsg" } }, amount: amt0 },
{ info: { native_token: { denom: "uusdc" } }, amount: amt1 },
];
const funds: Coin[] = [
{ denom: "umsg", amount: amt0 },
{ denom: "uusdc", amount: amt1 },
];
const hash = await dex.addLiquidity(PAIR_ADDR, assets, funds);
setTxHash(hash);
setAmount0("");
setAmount1("");
} catch (e: any) {
setError(e.message);
} finally {
setIsSubmitting(false);
}
};
const handleRemoveLiquidity = async () => {
if (!client || !address || !pool) return;
setIsSubmitting(true);
setError(null);
setTxHash(null);
try {
const dex = new DexClient(client, FACTORY_ADDR, address);
const lpAmount = (BigInt(pool.total_liquidity) * BigInt(removePercent) / BigInt(100)).toString();
const hash = await dex.removeLiquidity(PAIR_ADDR, lpAmount);
setTxHash(hash);
} catch (e: any) {
setError(e.message);
} finally {
setIsSubmitting(false);
}
};
if (!address) {
return (
<div className="flex items-center justify-center min-h-[80vh]">
<div className="bg-dex-card p-8 rounded-2xl border border-dex-border w-full max-w-md text-center">
<h2 className="text-2xl font-bold text-white mb-4">Connect Wallet</h2>
<button onClick={connect} className="bg-dex-accent text-white font-semibold py-3 px-8 rounded-xl transition">Connect Keplr</button>
</div>
</div>
);
}
return (
<div className="flex items-center justify-center min-h-[80vh]">
<div className="bg-dex-card p-6 rounded-2xl border border-dex-border w-full max-w-md">
<div className="flex mb-6 bg-dex-bg rounded-xl p-1">
<button onClick={() => setMode("add")} className={`flex-1 py-2 rounded-lg font-medium transition ${mode === "add" ? "bg-dex-accent text-white" : "text-dex-text-secondary"}`}>Add</button>
<button onClick={() => setMode("remove")} className={`flex-1 py-2 rounded-lg font-medium transition ${mode === "remove" ? "bg-dex-accent text-white" : "text-dex-text-secondary"}`}>Remove</button>
</div>
{mode === "add" ? (
<>
<div className="bg-dex-bg rounded-xl p-4 mb-3">
<label className="text-dex-text-secondary text-sm mb-2 block">Token 0 (umsg)</label>
<input type="number" value={amount0} onChange={e => setAmount0(e.target.value)} placeholder="0.0"
className="bg-transparent text-2xl text-white outline-none w-full" />
</div>
<div className="text-center text-dex-text-secondary mb-3">+</div>
<div className="bg-dex-bg rounded-xl p-4 mb-4">
<label className="text-dex-text-secondary text-sm mb-2 block">Token 1 (uusdc)</label>
<input type="number" value={amount1} onChange={e => setAmount1(e.target.value)} placeholder="0.0"
className="bg-transparent text-2xl text-white outline-none w-full" />
</div>
{pool && (
<div className="bg-dex-bg rounded-xl p-3 mb-4 text-sm text-dex-text-secondary">
<p>Pool: {pool.assets[0].amount} / {pool.assets[1].amount}</p>
<p>Rate: 1 umsg = {parseFloat(pool.assets[1].amount) / parseFloat(pool.assets[0].amount)} uusdc</p>
</div>
)}
{error && <p className="text-red-400 text-sm mb-2">{error}</p>}
{txHash && <p className="text-green-400 text-sm mb-2 break-all">Tx: {txHash}</p>}
<button onClick={handleAddLiquidity} disabled={isSubmitting || !amount0 || !amount1}
className="w-full bg-gradient-to-r from-green-500 to-emerald-600 text-white font-bold py-4 rounded-xl hover:opacity-90 transition disabled:opacity-40">
{isSubmitting ? "Adding..." : "Add Liquidity"}
</button>
</>
) : (
<>
<div className="bg-dex-bg rounded-xl p-4 mb-4">
<label className="text-dex-text-secondary text-sm mb-2 block">Remove Amount</label>
<div className="flex gap-2 mb-3 flex-wrap">
{[25, 50, 75, 100].map(p => (
<button key={p} onClick={() => setRemovePercent(p)}
className={`px-4 py-1 rounded-lg font-medium text-sm transition ${removePercent === p ? "bg-dex-accent text-white" : "bg-dex-border text-dex-text-secondary"}`}>{p}%</button>
))}
</div>
{pool && (
<p className="text-white text-lg">LP to remove: {removePercent}%</p>
)}
</div>
{pool && (
<div className="bg-dex-bg rounded-xl p-3 mb-4 text-sm text-dex-text-secondary">
<p>Your LP: {(BigInt(pool.total_liquidity) * BigInt(removePercent) / BigInt(100)).toString()}</p>
<p>You receive: ~{parseFloat(pool.assets[0].amount) * removePercent / 100} umsg / ~{parseFloat(pool.assets[1].amount) * removePercent / 100} uusdc</p>
</div>
)}
<button onClick={handleRemoveLiquidity} disabled={isSubmitting}
className="w-full bg-gradient-to-r from-red-500 to-rose-600 text-white font-bold py-4 rounded-xl hover:opacity-90 transition disabled:opacity-40">
{isSubmitting ? "Removing..." : "Remove Liquidity"}
</button>
</>
)}
</div>
</div>
);
}
8.7 Pool 页面
// src/pages/Pools.tsx
import React, { useEffect, useState } from "react";
import { useWallet } from "../hooks/useWallet";
import { DexClient } from "../utils/contracts";
import { useNavigate } from "react-router-dom";
const FACTORY_ADDR = "msg1factory...";
interface PoolRow {
pairAddr: string;
token0: string;
token1: string;
reserve0: string;
reserve1: string;
tvl: string;
volume24h: string;
fee24h: string;
}
export default function Pools() {
const { address, client, connect } = useWallet();
const [pools, setPools] = useState<PoolRow[]>([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
if (!client || !address) { setLoading(false); return; }
const dex = new DexClient(client, FACTORY_ADDR, address);
dex.getPairs(100).then(async (res: any) => {
const rows: PoolRow[] = [];
for (const p of res.pairs) {
try {
const pool = await dex.getPool(p.contract_addr);
const r0 = parseFloat(pool.assets[0].amount) / 1e18;
const r1 = parseFloat(pool.assets[1].amount) / 1e18;
rows.push({
pairAddr: p.contract_addr,
token0: p.asset_infos[0].native_token?.denom ?? p.asset_infos[0].token?.contract_addr ?? "?",
token1: p.asset_infos[1].native_token?.denom ?? p.asset_infos[1].token?.contract_addr ?? "?",
reserve0: r0.toFixed(4),
reserve1: r1.toFixed(4),
tvl: (r0 + r1).toFixed(2),
volume24h: "-",
fee24h: "-",
});
} catch (e) { /* skip */ }
}
setPools(rows);
setLoading(false);
}).catch(() => setLoading(false));
}, [client, address]);
if (!address) {
return (
<div className="flex items-center justify-center min-h-[80vh]">
<div className="bg-dex-card p-8 rounded-2xl border border-dex-border text-center">
<h2 className="text-2xl font-bold text-white mb-4">Connect to View Pools</h2>
<button onClick={connect} className="bg-dex-accent text-white font-semibold py-3 px-8 rounded-xl">Connect Keplr</button>
</div>
</div>
);
}
return (
<div className="max-w-5xl mx-auto py-8 px-4">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-white">Pools</h1>
<button onClick={() => navigate("/liquidity")}
className="bg-dex-accent text-white font-semibold py-2 px-6 rounded-xl">New Position</button>
</div>
{loading ? (
<div className="text-center text-dex-text-secondary py-20">Loading pools...</div>
) : pools.length === 0 ? (
<div className="text-center text-dex-text-secondary py-20">
<p className="text-lg mb-4">No pools found</p>
<button onClick={() => navigate("/liquidity")}
className="bg-dex-accent text-white font-semibold py-3 px-8 rounded-xl">Create Pool</button>
</div>
) : (
<div className="bg-dex-card rounded-2xl border border-dex-border overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-dex-border text-dex-text-secondary text-sm">
<th className="text-left py-4 px-6">Pool</th>
<th className="text-right py-4 px-6">Reserve 0</th>
<th className="text-right py-4 px-6">Reserve 1</th>
<th className="text-right py-4 px-6">TVL</th>
<th className="text-right py-4 px-6">Volume 24h</th>
<th className="text-right py-4 px-6">Fees 24h</th>
</tr>
</thead>
<tbody>
{pools.map((p, i) => (
<tr key={i} className="border-b border-dex-border hover:bg-dex-bg/50 cursor-pointer transition"
onClick={() => navigate(`/swap?pair=${p.pairAddr}`)}>
<td className="py-4 px-6">
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 border-2 border-dex-card" />
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-green-500 to-teal-500 border-2 border-dex-card" />
</div>
<span className="text-white font-medium">{p.token0.toUpperCase()} / {p.token1.toUpperCase()}</span>
</div>
</td>
<td className="text-right py-4 px-6 text-white">{p.reserve0}</td>
<td className="text-right py-4 px-6 text-white">{p.reserve1}</td>
<td className="text-right py-4 px-6 text-dex-green font-medium">${p.tvl}</td>
<td className="text-right py-4 px-6 text-dex-text-secondary">{p.volume24h}</td>
<td className="text-right py-4 px-6 text-dex-text-secondary">{p.fee24h}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
8.8 Dashboard 页面
// src/pages/Dashboard.tsx
import React, { useEffect, useState } from "react";
import { useWallet } from "../hooks/useWallet";
import { DexClient } from "../utils/contracts";
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
const FACTORY_ADDR = "msg1factory...";
export default function Dashboard() {
const { client, address } = useWallet();
const [stats, setStats] = useState({ totalPools: 0, totalLiquidity: 0, totalVolume24h: 0 });
const [chartData] = useState([
{ time: "00:00", volume: 10 }, { time: "04:00", volume: 25 },
{ time: "08:00", volume: 15 }, { time: "12:00", volume: 40 },
{ time: "16:00", volume: 30 }, { time: "20:00", volume: 20 },
]);
useEffect(() => {
if (!client || !address) return;
const dex = new DexClient(client, FACTORY_ADDR, address);
dex.getPairs(100).then(async (res: any) => {
const pools = res.pairs || [];
let tvl = 0;
for (const p of pools) {
try {
const pool = await dex.getPool(p.contract_addr);
tvl += parseFloat(pool.assets[0].amount) / 1e18 + parseFloat(pool.assets[1].amount) / 1e18;
} catch (e) { /* skip */ }
}
setStats({ totalPools: pools.length, totalLiquidity: tvl, totalVolume24h: 0 });
}).catch(console.error);
}, [client, address]);
return (
<div className="max-w-6xl mx-auto py-8 px-4">
<h1 className="text-3xl font-bold text-white mb-8">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-dex-card rounded-2xl border border-dex-border p-6">
<p className="text-dex-text-secondary text-sm mb-1">Total Pools</p>
<p className="text-3xl font-bold text-white">{stats.totalPools}</p>
</div>
<div className="bg-dex-card rounded-2xl border border-dex-border p-6">
<p className="text-dex-text-secondary text-sm mb-1">Total Liquidity</p>
<p className="text-3xl font-bold text-dex-green">${stats.totalLiquidity.toFixed(2)}</p>
</div>
<div className="bg-dex-card rounded-2xl border border-dex-border p-6">
<p className="text-dex-text-secondary text-sm mb-1">Volume 24h</p>
<p className="text-3xl font-bold text-dex-accent">${stats.totalVolume24h.toFixed(2)}</p>
</div>
</div>
<div className="bg-dex-card rounded-2xl border border-dex-border p-6 mb-8">
<h2 className="text-lg font-semibold text-white mb-4">Volume (24h)</h2>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<XAxis dataKey="time" stroke="#8a8ca0" fontSize={12} />
<YAxis stroke="#8a8ca0" fontSize={12} />
<Tooltip contentStyle={{ backgroundColor: "#1a1c2b", border: "1px solid #2a2d3e", borderRadius: "12px", color: "#fff" }} />
<Line type="monotone" dataKey="volume" stroke="#4f8cff" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-dex-card rounded-2xl border border-dex-border p-6">
<h2 className="text-lg font-semibold text-white mb-4">Quick Stats</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div><p className="text-dex-text-secondary">Chain</p><p className="text-white">MSG Chain</p></div>
<div><p className="text-dex-text-secondary">Block Time</p><p className="text-white">5s</p></div>
<div><p className="text-dex-text-secondary">Consensus</p><p className="text-white">RR + DAR</p></div>
<div><p className="text-dex-text-secondary">Signature</p><p className="text-white">Dilithium-5</p></div>
</div>
</div>
</div>
);
}
8.9 主应用和入口
// src/App.tsx
import React from "react";
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import Swap from "./pages/Swap";
import Liquidity from "./pages/Liquidity";
import Pools from "./pages/Pools";
import Dashboard from "./pages/Dashboard";
function NavBar() {
return (
<nav className="flex items-center justify-between px-6 py-4 border-b border-dex-border">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600" />
<span className="text-white font-bold text-lg">MSG DEX</span>
</div>
<div className="flex items-center gap-1">
{[
{ to: "/", label: "Swap" },
{ to: "/pools", label: "Pools" },
{ to: "/liquidity", label: "Liquidity" },
{ to: "/dashboard", label: "Dashboard" },
].map(link => (
<NavLink key={link.to} to={link.to} end={link.to === "/"}
className={({ isActive }) =>
`px-4 py-2 rounded-lg font-medium text-sm transition ${isActive ? "bg-dex-accent text-white" : "text-dex-text-secondary hover:text-white"}`
}>
{link.label}
</NavLink>
))}
</div>
</nav>
);
}
export default function App() {
return (
<BrowserRouter>
<div className="min-h-screen bg-dex-bg">
<NavBar />
<Routes>
<Route path="/" element={<Swap />} />
<Route path="/pools" element={<Pools />} />
<Route path="/liquidity" element={<Liquidity />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</div>
</BrowserRouter>
);
}
// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode><App /></React.StrictMode>
);
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
background-color: #0f111a;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MSG DEX</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
9. Part 7: 部署脚本与 Docker
9.1 部署配置
// scripts/config.ts
export const config = {
chainId: "msg-chain-1",
rpc: "https://rpc.msgchain.org",
prefix: "msg",
gasPrice: "1000000000attoMSG",
factory: {
owner: "msg1yourwallet...",
fee_to: null,
fee_bps: 30,
},
pair: {
fee_bps: 30,
},
router: {
// factory address will be set after deployment
},
tokens: [
{ denom: "umsg", type: "native", decimals: 18 },
{ contract: "msg1cw20contract...", type: "cw20", decimals: 18 },
],
};
9.2 部署脚本 (TypeScript)
// scripts/deploy.ts
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { GasPrice, calculateFee } from "@cosmjs/stargate";
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";
import { readFileSync } from "fs";
import { config } from "./config";
async function deploy() {
// 1. 加载部署账户
const mnemonic = process.env.MNEMONIC || "your seed phrase here";
const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, {
prefix: config.prefix,
});
const [account] = await wallet.getAccounts();
console.log("Deploying from:", account.address);
// 2. 连接 MSG Chain
const client = await SigningCosmWasmClient.connectWithSigner(
config.rpc,
wallet,
{ gasPrice: GasPrice.fromString(config.gasPrice) }
);
// 3. 上传合约 WASM 代码
console.log("\n=== Uploading contracts ===");
const factoryWasm = readFileSync("./contracts/factory/target/wasm32-unknown-unknown/release/factory.wasm");
const factoryCodeId = (await client.upload(account.address, factoryWasm, "auto")).codeId;
console.log("Factory code ID:", factoryCodeId);
const pairWasm = readFileSync("./contracts/pair/target/wasm32-unknown-unknown/release/pair.wasm");
const pairCodeId = (await client.upload(account.address, pairWasm, "auto")).codeId;
console.log("Pair code ID:", pairCodeId);
const routerWasm = readFileSync("./contracts/router/target/wasm32-unknown-unknown/release/router.wasm");
const routerCodeId = (await client.upload(account.address, routerWasm, "auto")).codeId;
console.log("Router code ID:", routerCodeId);
// 4. 实例化 Factory 合约
console.log("\n=== Instantiating Factory ===");
const factoryAddr = await client.instantiate(
account.address,
factoryCodeId,
{
owner: config.factory.owner,
fee_to: config.factory.fee_to,
pair_code_id: pairCodeId,
fee_bps: config.factory.fee_bps,
},
"MSG DEX Factory",
"auto"
);
console.log("Factory address:", factoryAddr);
// 5. 创建交易对
console.log("\n=== Creating Pairs ===");
for (const token of config.tokens) {
const assetInfo = token.type === "native"
? { native_token: { denom: token.denom } }
: { token: { contract_addr: token.contract } };
// 示例:umsg / msg_token_cw20 交易对
const nativeAsset = { native_token: { denom: "umsg" } };
const tx = await client.execute(
account.address,
factoryAddr,
{
create_pair: {
asset_infos: [nativeAsset, assetInfo],
init_params: null,
},
},
"auto",
"Create Pair"
);
console.log("Created pair, tx:", tx.transactionHash);
}
// 6. 实例化 Router 合约
console.log("\n=== Instantiating Router ===");
const routerAddr = await client.instantiate(
account.address,
routerCodeId,
{ factory_addr: factoryAddr },
"MSG DEX Router",
"auto"
);
console.log("Router address:", routerAddr);
// 7. 输出部署摘要
console.log("\n=== Deployment Summary ===");
console.log(JSON.stringify({
factoryCodeId,
pairCodeId,
routerCodeId,
factoryAddr,
routerAddr,
}, null, 2));
}
deploy().catch(console.error);
9.3 部署脚本 (Shell)
#!/bin/bash
# scripts/deploy.sh
set -e
# MSG Chain 部署脚本
CHAIN_ID="msg-chain-1"
NODE="https://rpc.msgchain.org"
KEY_NAME="deployer"
KEYRING="--keyring-backend test"
echo "=== MSG DEX Deployment ==="
echo "Chain: $CHAIN_ID"
# 编译合约
echo "Building contracts..."
cd contracts/factory && cargo wasm && cd ../..
cd contracts/pair && cargo wasm && cd ../..
cd contracts/router && cargo wasm && cd ../..
# 优化 WASM
echo "Optimizing WASM..."
for contract in factory pair router; do
wasm-opt -Os \
"target/wasm32-unknown-unknown/release/${contract}.wasm" \
-o "artifacts/${contract}.wasm"
done
# 上传代码
echo "Uploading Factory..."
FACTORY_CODE_ID=$(msgd tx wasm store artifacts/factory.wasm \
--from $KEY_NAME $KEYRING --chain-id $CHAIN_ID --node $NODE \
--gas-prices 1000000000attoMSG --gas auto --gas-adjustment 1.3 -y -o json | \
jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "Factory Code ID: $FACTORY_CODE_ID"
echo "Uploading Pair..."
PAIR_CODE_ID=$(msgd tx wasm store artifacts/pair.wasm \
--from $KEY_NAME $KEYRING --chain-id $CHAIN_ID --node $NODE \
--gas-prices 1000000000attoMSG --gas auto --gas-adjustment 1.3 -y -o json | \
jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "Pair Code ID: $PAIR_CODE_ID"
echo "Uploading Router..."
ROUTER_CODE_ID=$(msgd tx wasm store artifacts/router.wasm \
--from $KEY_NAME $KEYRING --chain-id $CHAIN_ID --node $NODE \
--gas-prices 1000000000attoMSG --gas auto --gas-adjustment 1.3 -y -o json | \
jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "Router Code ID: $ROUTER_CODE_ID"
# 实例化 Factory
echo "Instantiating Factory..."
FACTORY_ADDR=$(msgd tx wasm instantiate $FACTORY_CODE_ID \
"{\"owner\":\"$OWNER_ADDR\",\"pair_code_id\":$PAIR_CODE_ID,\"fee_bps\":30}" \
--from $KEY_NAME $KEYRING --chain-id $CHAIN_ID --node $NODE \
--label "MSG-DEX-Factory" --gas-prices 1000000000attoMSG --gas auto --gas-adjustment 1.3 -y -o json | \
jq -r '.logs[0].events[] | select(.type=="instantiate") | .attributes[] | select(.key=="_contract_address") | .value')
echo "Factory: $FACTORY_ADDR"
# 创建 umsg / msg_token_cw20 交易对
echo "Creating pair..."
msgd tx wasm execute $FACTORY_ADDR \
"{\"create_pair\":{\"asset_infos\":[{\"native_token\":{\"denom\":\"umsg\"}},{\"token\":{\"contract_addr\":\"$CW20_ADDR\"}}]}}" \
--from $KEY_NAME $KEYRING --chain-id $CHAIN_ID --node $NODE \
--gas-prices 1000000000attoMSG --gas auto --gas-adjustment 1.3 -y
# 实例化 Router
echo "Instantiating Router..."
ROUTER_ADDR=$(msgd tx wasm instantiate $ROUTER_CODE_ID \
"{\"factory_addr\":\"$FACTORY_ADDR\"}" \
--from $KEY_NAME $KEYRING --chain-id $CHAIN_ID --node $NODE \
--label "MSG-DEX-Router" --gas-prices 1000000000attoMSG --gas auto --gas-adjustment 1.3 -y -o json | \
jq -r '.logs[0].events[] | select(.type=="instantiate") | .attributes[] | select(.key=="_contract_address") | .value')
echo "Router: $ROUTER_ADDR"
echo ""
echo "=== Deployment Complete ==="
echo "Summary:"
echo " Factory: $FACTORY_ADDR"
echo " Router: $ROUTER_ADDR"
9.4 添加初始流动性
// scripts/add-liquidity.ts
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { GasPrice } from "@cosmjs/stargate";
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";
async function addInitialLiquidity() {
const mnemonic = process.env.MNEMONIC!;
const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { prefix: "msg" });
const [account] = await wallet.getAccounts();
const client = await SigningCosmWasmClient.connectWithSigner(
"https://rpc.msgchain.org",
wallet,
{ gasPrice: GasPrice.fromString("1000000000attoMSG") }
);
const factoryAddr = "msg1factory..."; // 替换为实际部署地址
const cw20Addr = "msg1cw20token..."; // MSG Chain 上的 CW20 代币
// 查询工厂获取 Pair 地址
const pairInfo: any = await client.queryContractSmart(factoryAddr, {
pair: {
asset_infos: [
{ native_token: { denom: "umsg" } },
{ token: { contract_addr: cw20Addr } },
],
},
});
const pairAddr = pairInfo.contract_addr;
console.log("Pair address:", pairAddr);
// 添加初始流动性
const umsgAmount = "1000000000000000000"; // 1 MSG
const tokenAmount = "2000000000000000000"; // 2 tokens
const tx = await client.execute(
account.address,
pairAddr,
{
provide_liquidity: {
assets: [
{ info: { native_token: { denom: "umsg" } }, amount: umsgAmount },
{ info: { token: { contract_addr: cw20Addr } }, amount: tokenAmount },
],
slippage_tolerance: null,
auto_stake: null,
receiver: null,
},
},
"auto",
"Add Initial Liquidity",
[{ denom: "umsg", amount: umsgAmount }]
);
console.log("Initial liquidity added, tx:", tx.transactionHash);
console.log("LP tokens minted to:", account.address);
}
addInitialLiquidity().catch(console.error);
9.5 Docker Compose
# docker-compose.yml
version: "3.8"
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:80"
environment:
- VITE_CHAIN_ID=msg-chain-1
- VITE_RPC_URL=https://rpc.msgchain.org
- VITE_REST_URL=https://api.msgchain.org
- VITE_FACTORY_ADDR=msg1factory...
depends_on:
- indexer
indexer:
build:
context: ./indexer
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- MSG_RPC=https://rpc.msgchain.org
- FACTORY_ADDR=msg1factory...
volumes:
- indexer_data:/data
# 可选: MSG Chain 轻节点
msg-node:
image: msgchain/node:latest
ports:
- "26657:26657"
- "1317:1317"
volumes:
- msg_data:/root/.msgd
environment:
- MONIKER=dex-node
- CHAIN_ID=msg-chain-1
volumes:
indexer_data:
msg_data:
# frontend/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# frontend/nginx.conf
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://indexer:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
9.6 前端部署
# 构建前端
cd frontend
npm install
npm run build
# 部署到 IPFS(可选)
npx ipfs-deploy dist
# 或使用 Docker
docker build -t msg-dex-frontend .
docker run -d -p 3000:80 msg-dex-frontend
9.7 Makefile
# Makefile
.PHONY: all build test deploy clean
all: build test
build:
@echo "Building contracts..."
cd contracts/factory && cargo wasm
cd contracts/pair && cargo wasm
cd contracts/router && cargo wasm
@echo "Building frontend..."
cd frontend && npm ci && npm run build
test:
@echo "Running tests..."
cargo test --workspace
cd frontend && npm test 2>/dev/null || echo "No frontend tests configured"
optimize:
@echo "Optimizing WASM..."
mkdir -p artifacts
for contract in factory pair router; do \
wasm-opt -Os target/wasm32-unknown-unknown/release/$$contract.wasm -o artifacts/$$contract.wasm; \
done
schema:
@echo "Generating schemas..."
cargo schema
deploy:
@echo "Deploying contracts..."
bash scripts/deploy.sh
frontend-deploy:
@echo "Deploying frontend..."
cd frontend && npm run build
clean:
cargo clean
rm -rf artifacts
rm -rf frontend/dist
docker-build:
docker-compose build
docker-up:
docker-compose up -d
docker-down:
docker-compose down
10. 完整工作流示例
10.1 从头部署到使用
# 1. 克隆 & 编译
git clone https://github.com/msgchain/msg-dex.git
cd msg-dex
make build
# 2. 测试
make test
# 3. 部署合约
make deploy
# 4. 添加初始流动性
npx ts-node scripts/add-liquidity.ts
# 5. 启动前端
make frontend-deploy
make docker-up
10.2 用户交互流程
1. 用户打开 MSG DEX (http://localhost:3000)
2. 点击 "Connect Keplr"
3. Keplr 弹出提示添加 MSG Chain
4. 用户签名连接
--- Swap ---
5. 选择 From: MSG, To: USDC
6. 输入 100 MSG
7. 查看预计输出、价格、滑点
8. 点击 "Swap"
9. Keplr 弹出交易确认
10. 用户确认 → 交易上链 → 收到 USDC
--- Add Liquidity ---
5. 进入 Liquidity 页面
6. 输入 MSG 和 USDC 数量
7. 点击 "Add Liquidity"
8. 确认交易 → 收到 LP 代币
--- Remove Liquidity ---
5. 切换到 Remove 模式
6. 选择百分比 (25%, 50%, 75%, 100%)
7. 点击 "Remove Liquidity"
8. 确认交易 → 收回资产
10.3 合约交互数据流
创建交易对:
User → Factory.createPair(assets)
→ Factory 用 SubMsg 实例化 Pair 合约
→ Pair 实例化时创建 LP Token (CW20)
→ Reply 返回 Pair 地址
→ Factory 注册 Pair 到 PAIRS 存储
交换:
User → Pair.swap(offer, belief_price, max_spread, to)
→ 验证资产 (Native/CW20)
→ compute_swap() 计算输出
→ 检查滑点/价格保护
→ 更新储备和 TWAP
→ 发送输出资产给 to 地址
添加流动性:
User → Pair.provide(assets, slippage_tolerance)
→ 检查比例(如果已有流动性)
→ compute_lp_tokens_to_mint()
→ 更新储备和总流动性
→ Mint LP 代币给用户
→ 首次时销毁 1000 LP
移除流动性:
User → Pair.withdraw(liquidity, min_assets)
→ compute_withdraw_amounts()
→ 检查最小资产量
→ 更新储备
→ BurnFrom LP 代币
→ 发送资产给用户
11. 安全注意事项
11.1 合约安全
- 重入攻击保护: 所有状态更新在外部调用之前完成
- 溢出保护: 使用
checked_*方法进行安全数学运算 - 权限控制: 只有 Factory 所有者可以更新配置
- 滑点保护: 提供
belief_price和max_spread参数 - 最小流动性: 永久销毁 1000 LP 代币防止操纵
- TWAP 操纵: 价格累计使用区块时间加权
11.2 MSG Chain 特有安全
- Dilithium-5 签名: 交易签名使用后量子密码学,需确保 Keplr 支持
- 共识安全: Round-Robin + DAR 提供快速最终性(5 秒出块)
- Gas 管理: 建议使用
auto模式,设置合适的 gas 乘数
11.3 前端安全
- 交易模拟: 所有交易先通过 query 模拟,再发送执行
- 滑点设置: 默认滑点 0.5%,用户可自定义
- 价格保护: 显示价格影响警告(>5% 时高亮)
- 连接安全: 只通过 Keplr 扩展连接,不存储私钥
12. 性能优化
12.1 合约优化
- 使用
cw-storage-plus的Map代替Storage直接操作 - 批量消息使用
add_messages减少 Gas - 查询使用分页限制(默认 30,最大 100)
- WASM 优化使用
wasm-opt -Os
12.2 前端优化
- 查询缓存(React Query / SWR)
- 懒加载页面组件
- 交易模拟本地缓存
- 使用 WebWorker 处理大数计算
12.3 索引器优化
- 事件监听 + 数据库存储
- 缓存 TVL 和 24h 交易量
- WebSocket 推送实时更新
13. 故障排除
| 问题 | 原因 | 解决方案 |
|---|---|---|
Unauthorized |
发送者不是合约所有者 | 使用正确的所有者地址 |
Pair already exists |
交易对已创建 | 查询工厂确认 |
Slippage exceeded |
市场价格波动 | 增加滑点容忍度 |
Insufficient liquidity |
池子深度不足 | 等待更多流动性 |
Native balance mismatch |
未附带足够资金 | 在 funds 中包含正确金额 |
| Keplr 连接失败 | MSG Chain 未配置 | 使用 experimentalSuggestChain |
| 交易失败 | Gas 不足 | 增加 gas 限制 |
14. 总结
本文档完整实现了基于 CosmWasm 1.x 的 Uniswap V2 风格 AMM DEX,涵盖:
- Factory 合约: 创建和管理交易对(681 行 Rust)
- Pair 合约: 恒定乘积 AMM 核心(986 行 Rust,含数学库和测试)
- Router 合约: 多跳交换路由(372 行 Rust)
- LP Token: 内嵌 CW20 标准实现
- 测试: 完整的 cw-multi-test 集成测试和数学库单元测试(634 行)
- 前端: 完整的 React + TypeScript + Tailwind 界面(779 行 TypeScript)
- 部署: TypeScript 和 Shell 部署脚本 + Docker Compose
整个 DEX 针对 MSG Chain(msg-chain-1)优化,支持 Dilithium-5 后量子签名和 RR+DAR 共识机制。
本文档由 MSG Chain 开发团队生成
本文档基于 MSG Chain 代码库核实的技术事实。
白皮书系统: https://msgchain.org/whitepaper/
