MSG Chain CW20 代币标准与工厂实现 — 完整开发参考级指南
版本: v1.0.0
目标链:msg-chain-1
Bech32 前缀:msg
主网状态: No-Go
Gas 价格: 1,000,000,000 attoMSG/gas
MSG 精度: 18 位小数
签名方案: Dilithium-5 (后量子密码学)
共识: Round-Robin + DAR
目录
1. CW20 标准详解
1.1 什么是 CW20?
CW20 是 CosmWasm 生态中的代币标准,类似于 Ethereum 上的 ERC20。它定义了同质化代币(Fungible Token)的通用接口,包括转账、授权、铸造、销毁等操作。MSG Chain 原生支持 CW20 标准,在 contracts/cosmwasm/all/ 中包含 cw20_base 作为标准合约包。
1.2 CW20 核心接口
CW20 标准由 cw20 crate 定义(https://github.com/CosmWasm/cw-plus/blob/main/packages/cw20/README.md),包含以下核心组件:
1.2.1 ExecuteMsg — 执行消息
pub enum Cw20ExecuteMsg {
/// 转账:将代币从 sender 发送到 recipient
Transfer { recipient: String, amount: Uint128 },
/// 授权转账:sender 授权 spender 可花费 amount 代币
Approve { spender: String, amount: Uint128 },
/// 授权转账(减少模式):将 spender 的额度减少 amount
DecreaseAllowance { spender: String, amount: Uint128, expires: Option<Expiration> },
/// 授权转账(增加模式):将 spender 的额度增加 amount
IncreaseAllowance { spender: String, amount: Uint128, expires: Option<Expiration> },
/// 转移授权代币:从 owner 处转移代币到 recipient
TransferFrom { owner: String, recipient: String, amount: Uint128 },
/// 发送代币到合约并触发接收处理
Send { contract: String, amount: Uint128, msg: Binary },
/// 销毁代币
Burn { amount: Uint128 },
/// 铸造代币(仅 minter 可调用)
Mint { recipient: String, amount: Uint128 },
/// 更新 minter(仅当前 minter 可调用)
UpdateMinter { new_minter: Option<String> },
/// 设置营销信息
SetMarketing { project: Option<String>, description: Option<String>, marketing: Option<String> },
/// 上传 logo
UploadLogo(Logo),
/// 使用授权发送到合约
SendFrom { owner: String, contract: String, amount: Uint128, msg: Binary },
/// 使用授权销毁
BurnFrom { owner: String, amount: Uint128 },
}
1.2.2 QueryMsg — 查询消息
pub enum Cw20QueryMsg {
/// 查询代币余额
Balance { address: String },
/// 查询代币信息(名称、符号、精度、总量)
TokenInfo {},
/// 查询铸造权限
Minter {},
/// 查询授权额度
Allowance { owner: String, spender: String },
/// 查询所有持有者账户(需 Enumerable 扩展)
AllAccounts { start_after: Option<String>, limit: Option<u32> },
/// 查询所有授权(需 Enumerable 扩展)
AllAllowances { owner: String, start_after: Option<String>, limit: Option<u32> },
/// 查询营销信息
MarketingInfo {},
/// 查询下载 logo 信息
DownloadLogo {},
}
1.2.3 响应类型
pub struct BalanceResponse { pub balance: Uint128 }
pub struct TokenInfoResponse { pub name: String, pub symbol: String, pub decimals: u8, pub total_supply: Uint128 }
pub struct MinterResponse { pub minter: Option<String>, pub cap: Option<Uint128> }
pub struct AllowanceResponse { pub allowance: Uint128, pub expires: Expiration }
pub struct AllAccountsResponse { pub accounts: Vec<String> }
pub struct AllAllowancesResponse { pub allowances: Vec<AllowanceInfo> }
pub struct AllowanceInfo { pub owner: String, pub spender: String, pub allowance: Uint128, pub expires: Expiration }
pub struct MarketingInfoResponse { pub project: Option<String>, pub description: Option<String>, pub marketing: Option<String>, pub logo: Option<LogoInfo> }
pub enum LogoInfo { Url(String), Embedded }
1.3 数据存储结构
CW20 标准合约使用以下存储布局:
pub const TOKEN_INFO: Item<TokenInfo> = Item::new("token_info");
pub const BALANCES: Map<&Addr, Uint128> = Map::new("balance");
pub const ALLOWANCES: Map<(&Addr, &Addr), Allowance> = Map::new("allowance");
pub struct TokenInfo {
pub name: String,
pub symbol: String,
pub decimals: u8,
pub total_supply: Uint128,
pub mint: Option<MinterData>,
}
pub struct MinterData {
pub minter: Addr,
pub cap: Option<Uint128>,
}
pub struct Allowance {
pub allowance: Uint128,
pub expires: Expiration,
}
1.4 CW20 扩展功能
CW20 标准通过特性标记支持以下扩展:
| 扩展 | 描述 | 必需字段 |
|---|---|---|
| Allowances | 授权转账(Approve/TransferFrom) | 基本功能 |
| Mintable | 可铸造(Mint/UpdateMinter) | mint 字段 |
| Burnable | 可销毁(Burn) | 基本功能 |
| Marketing | 营销信息(SetMarketing/UploadLogo) | 可选 |
| Enumerable | 可枚举(AllAccounts/AllAllowances) | 可选 |
1.5 与 ERC20 的关键差异
| 特性 | ERC20 | CW20 |
|---|---|---|
| 授权方式 | approve 设置绝对额度 |
approve 设置绝对额度 |
| 授权保护 | 无默认保护 | IncreaseAllowance / DecreaseAllowance 可防重入 |
| 过期时间 | 无 | Expiration 支持高度/时间过期 |
| 元信息 | symbol/name/decimals 固定 | 链上可更新的营销信息 |
| 总量追踪 | totalSupply() |
TokenInfo.total_supply |
| 合约接收 | 需要额外注册 | Send 消息 + Cw20ReceiveMsg 回调 |
1.6 Expiration 类型
pub enum Expiration {
AtHeight(u64), // 在指定区块高度后过期
AtTime(Timestamp), // 在指定时间戳(秒)后过期
Never {}, // 永不过期
}
impl Expiration {
pub fn is_expired(&self, height: u64, time: u64) -> bool {
match self {
Expiration::AtHeight(h) => height >= *h,
Expiration::AtTime(t) => time >= t.seconds(),
Expiration::Never {} => false,
}
}
}
1.7 Cw20ReceiveMsg — 合约接收回调
当使用 Send 消息转账到合约时,目标合约会收到:
pub struct Cw20ReceiveMsg {
pub sender: String, // 发送者地址
pub amount: Uint128, // 发送数量
pub msg: Binary, // 可选的回调数据
}
目标合约需要实现 receive 处理函数来解析此消息。这是 CW20 与 ERC20 的关键区别之一——CW20 原生支持代币+数据的一步式操作(类似 ERC20 的 approve + transferFrom 合一但更安全)。
2. 合约完整实现 — cw20_base
2.1 项目结构
cw20-base/
├── Cargo.toml
├── src/
│ ├── lib.rs # 库入口
│ ├── contract.rs # 合约入口点
│ ├── msg.rs # 消息类型
│ ├── state.rs # 状态存储
│ ├── enumerable.rs # Enumerable 扩展
│ ├── allowances.rs # Allowances 扩展
│ └── marketing.rs # Marketing 扩展
├── examples/
│ └── schema.rs
├── tests/
│ └── integration.rs
└── schema/
2.2 Cargo.toml
[package]
name = "cw20-base"
version = "1.1.0"
edition = "2021"
description = "MSG Chain CW20 base token contract"
[lib]
crate-type = ["cdylib", "rlib"]
[profile.release]
opt-level = 3
debug = false
rpath = false
lto = true
debug-assertions = false
codegen-units = 1
panic = "abort"
incremental = false
overflow-checks = true
[dependencies]
cosmwasm-std = "1.5"
cosmwasm-storage = "1.5"
cw-storage-plus = "1.2"
cw2 = "1.1"
cw20 = "1.1"
schemars = "0.8"
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
uint = "0.9"
[dev-dependencies]
cosmwasm-vm = "1.5"
cw-multi-test = "0.18"
anyhow = "1.0"
2.3 src/state.rs
use cosmwasm_schema::cw_serde;
use cosmwasm_std::{Addr, Binary, StdResult, Storage, Uint128};
use cw_storage_plus::{Item, Map};
#[cw_serde]
pub struct TokenInfo {
pub name: String,
pub symbol: String,
pub decimals: u8,
pub total_supply: Uint128,
pub mint: Option<MinterData>,
}
#[cw_serde]
pub struct MinterData {
pub minter: Addr,
pub cap: Option<Uint128>,
}
#[cw_serde]
pub struct Allowance {
pub allowance: Uint128,
pub expires: Expiration,
}
#[cw_serde]
pub enum Expiration {
AtHeight(u64),
AtTime(u64),
Never {},
}
impl Expiration {
pub fn is_expired(&self, height: u64, time: u64) -> bool {
match self {
Expiration::AtHeight(h) => height >= *h,
Expiration::AtTime(t) => time >= *t,
Expiration::Never {} => false,
}
}
}
pub const TOKEN_INFO: Item<TokenInfo> = Item::new("token_info");
pub const BALANCES: Map<&Addr, Uint128> = Map::new("balance");
pub const ALLOWANCES: Map<(&Addr, &Addr), Allowance> = Map::new("allowance");
pub fn validate_address(api: &dyn cosmwasm_std::Api, addr: &str) -> StdResult<Addr> {
api.addr_validate(addr)
}
2.4 src/msg.rs
use cosmwasm_schema::cw_serde;
use cosmwasm_std::{Binary, Uint128};
use crate::state::Expiration;
#[cw_serde]
pub struct InstantiateMsg {
pub name: String,
pub symbol: String,
pub decimals: u8,
pub initial_balances: Vec<Cw20Coin>,
pub mint: Option<MinterResponse>,
pub marketing: Option<InstantiateMarketingInfo>,
}
#[cw_serde]
pub struct Cw20Coin {
pub address: String,
pub amount: Uint128,
}
#[cw_serde]
pub struct MinterResponse {
pub minter: String,
pub cap: Option<Uint128>,
}
#[cw_serde]
pub struct InstantiateMarketingInfo {
pub project: Option<String>,
pub description: Option<String>,
pub marketing: Option<String>,
pub logo: Option<Logo>,
}
#[cw_serde]
pub enum Logo {
Url(String),
Embedded(Binary),
}
#[cw_serde]
pub enum ExecuteMsg {
Transfer { recipient: String, amount: Uint128 },
Burn { amount: Uint128 },
Send { contract: String, amount: Uint128, msg: Binary },
Mint { recipient: String, amount: Uint128 },
UpdateMinter { new_minter: Option<String> },
Approve { spender: String, amount: Uint128, expires: Option<Expiration> },
IncreaseAllowance { spender: String, amount: Uint128, expires: Option<Expiration> },
DecreaseAllowance { spender: String, amount: Uint128, expires: Option<Expiration> },
TransferFrom { owner: String, recipient: String, amount: Uint128 },
BurnFrom { owner: String, amount: Uint128 },
SendFrom { owner: String, contract: String, amount: Uint128, msg: Binary },
SetMarketing { project: Option<String>, description: Option<String>, marketing: Option<String> },
UploadLogo(Logo),
}
#[cw_serde]
pub enum QueryMsg {
Balance { address: String },
TokenInfo {},
Minter {},
Allowance { owner: String, spender: String },
AllAccounts { start_after: Option<String>, limit: Option<u32> },
AllAllowances { owner: String, start_after: Option<String>, limit: Option<u32> },
MarketingInfo {},
DownloadLogo {},
}
#[cw_serde]
pub struct BalanceResponse { pub balance: Uint128 }
#[cw_serde]
pub struct TokenInfoResponse { pub name: String, pub symbol: String, pub decimals: u8, pub total_supply: Uint128 }
#[cw_serde]
pub struct AllowanceResponse { pub allowance: Uint128, pub expires: Expiration }
#[cw_serde]
pub struct AllAccountsResponse { pub accounts: Vec<String> }
#[cw_serde]
pub struct AllAllowancesResponse { pub allowances: Vec<AllowanceInfo> }
#[cw_serde]
pub struct AllowanceInfo { pub owner: String, pub spender: String, pub allowance: Uint128, pub expires: Expiration }
#[cw_serde]
pub struct MarketingInfoResponse { pub project: Option<String>, pub description: Option<String>, pub marketing: Option<String>, pub logo: Option<LogoInfo> }
#[cw_serde]
pub enum LogoInfo { Url(String), Embedded }
2.5 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 - sender is not the minter")]
Unauthorized {},
#[error("Cannot transfer zero amount")]
ZeroAmount {},
#[error("Insufficient balance: balance={balance}, required={required}")]
InsufficientBalance { balance: Uint128, required: Uint128 },
#[error("Insufficient allowance: allowance={allowance}, required={required}")]
InsufficientAllowance { allowance: Uint128, required: Uint128 },
#[error("Allowance is expired")]
Expired {},
#[error("Mint cap exceeded: cap={cap}, total_supply would be {total}")]
CapExceeded { cap: Uint128, total: Uint128 },
#[error("Minting is not allowed on this token")]
NotMintable {},
#[error("Logo binary too large: {size} bytes, max {max} bytes")]
LogoTooLarge { size: u64, max: u64 },
}
2.6 src/contract.rs — 完整实现
use cosmwasm_std::{
entry_point, to_binary, Binary, Deps, DepsMut, Env, MessageInfo,
Response, StdResult, Uint128, WasmMsg, CosmosMsg, StdError,
};
use cw2::set_contract_version;
use crate::error::ContractError;
use crate::msg::{
AllAccountsResponse, AllAllowancesResponse, AllowanceInfo,
AllowanceResponse, BalanceResponse, ExecuteMsg, InstantiateMsg,
MarketingInfoResponse, MinterResponse, QueryMsg, TokenInfoResponse,
};
use crate::state::{
validate_address, Allowance, Expiration, MinterData, TokenInfo,
ALLOWANCES, BALANCES, TOKEN_INFO,
};
const CONTRACT_NAME: &str = "cw20-base";
const CONTRACT_VERSION: &str = "1.1.0";
#[entry_point]
pub fn instantiate(
deps: DepsMut,
_env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> StdResult<Response> {
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
if msg.decimals > 18 {
return Err(StdError::generic_err("Decimals must not exceed 18"));
}
let mint = match msg.mint {
Some(m) => {
let minter_addr = deps.api.addr_validate(&m.minter)?;
Some(MinterData { minter: minter_addr, cap: m.cap })
}
None => None,
};
let total_supply = msg.initial_balances.iter()
.map(|c| c.amount).sum::<Uint128>();
let info = TokenInfo {
name: msg.name, symbol: msg.symbol, decimals: msg.decimals,
total_supply, mint,
};
TOKEN_INFO.save(deps.storage, &info)?;
for coin in &msg.initial_balances {
let addr = deps.api.addr_validate(&coin.address)?;
BALANCES.save(deps.storage, &addr, &coin.amount)?;
}
Ok(Response::new()
.add_attribute("method", "instantiate")
.add_attribute("name", &info.name)
.add_attribute("symbol", &info.symbol)
.add_attribute("decimals", info.decimals.to_string())
.add_attribute("total_supply", info.total_supply.to_string()))
}
#[entry_point]
pub fn execute(
deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg,
) -> Result<Response, ContractError> {
match msg {
ExecuteMsg::Transfer { recipient, amount } => execute_transfer(deps, env, info, recipient, amount),
ExecuteMsg::Burn { amount } => execute_burn(deps, env, info, amount),
ExecuteMsg::Mint { recipient, amount } => execute_mint(deps, env, info, recipient, amount),
ExecuteMsg::UpdateMinter { new_minter } => execute_update_minter(deps, env, info, new_minter),
ExecuteMsg::Approve { spender, amount, expires } => execute_approve(deps, env, info, spender, amount, expires),
ExecuteMsg::IncreaseAllowance { spender, amount, expires } => execute_increase_allowance(deps, env, info, spender, amount, expires),
ExecuteMsg::DecreaseAllowance { spender, amount, expires } => execute_decrease_allowance(deps, env, info, spender, amount, expires),
ExecuteMsg::TransferFrom { owner, recipient, amount } => execute_transfer_from(deps, env, info, owner, recipient, amount),
ExecuteMsg::BurnFrom { owner, amount } => execute_burn_from(deps, env, info, owner, amount),
ExecuteMsg::Send { contract, amount, msg } => execute_send(deps, env, info, contract, amount, msg),
ExecuteMsg::SendFrom { owner, contract, amount, msg } => execute_send_from(deps, env, info, owner, contract, amount, msg),
ExecuteMsg::SetMarketing { project, description, marketing } => execute_set_marketing(deps, env, info, project, description, marketing),
ExecuteMsg::UploadLogo(logo) => execute_upload_logo(deps, env, info, logo),
}
}
fn check_balance(storage: &dyn cosmwasm_std::Storage, owner: &Addr, amount: Uint128) -> Result<(), ContractError> {
let balance = BALANCES.may_load(storage, owner)?.unwrap_or_default();
if balance < amount {
return Err(ContractError::InsufficientBalance { balance, required: amount });
}
Ok(())
}
fn transfer_balance(storage: &mut dyn cosmwasm_std::Storage, from: &Addr, to: &Addr, amount: Uint128) -> Result<(), ContractError> {
if amount.is_zero() { return Err(ContractError::ZeroAmount {}); }
BALANCES.update(storage, from, |bal: Option<Uint128>| -> StdResult<_> {
Ok(bal.unwrap_or_default().checked_sub(amount).map_err(|_| StdError::generic_err("Insufficient balance"))?)
})?;
BALANCES.update(storage, to, |bal: Option<Uint128>| -> StdResult<_> {
Ok(bal.unwrap_or_default() + amount)
})?;
Ok(())
}
fn execute_transfer(deps: DepsMut, _env: Env, info: MessageInfo, recipient: String, amount: Uint128) -> Result<Response, ContractError> {
if amount.is_zero() { return Err(ContractError::ZeroAmount {}); }
let rcpt = deps.api.addr_validate(&recipient)?;
transfer_balance(deps.storage, &info.sender, &rcpt, amount)?;
Ok(Response::new()
.add_attribute("action", "transfer")
.add_attribute("from", info.sender.as_str())
.add_attribute("to", &recipient)
.add_attribute("amount", amount.to_string()))
}
fn execute_burn(deps: DepsMut, _env: Env, info: MessageInfo, amount: Uint128) -> Result<Response, ContractError> {
if amount.is_zero() { return Err(ContractError::ZeroAmount {}); }
let sender = &info.sender;
check_balance(deps.storage, sender, amount)?;
BALANCES.update(deps.storage, sender, |bal: Option<Uint128>| -> StdResult<_> {
Ok(bal.unwrap_or_default().checked_sub(amount)?)
})?;
TOKEN_INFO.update(deps.storage, |mut info| -> StdResult<_> {
info.total_supply = info.total_supply.checked_sub(amount)?;
Ok(info)
})?;
Ok(Response::new()
.add_attribute("action", "burn")
.add_attribute("from", sender.as_str())
.add_attribute("amount", amount.to_string()))
}
fn execute_mint(deps: DepsMut, _env: Env, info: MessageInfo, recipient: String, amount: Uint128) -> Result<Response, ContractError> {
if amount.is_zero() { return Err(ContractError::ZeroAmount {}); }
let token_info = TOKEN_INFO.load(deps.storage)?;
let mint = token_info.mint.as_ref().ok_or(ContractError::NotMintable {})?;
if info.sender != mint.minter { return Err(ContractError::Unauthorized {}); }
let new_supply = token_info.total_supply.checked_add(amount)
.map_err(|_| StdError::generic_err("Total supply overflow"))?;
if let Some(cap) = mint.cap {
if new_supply > cap { return Err(ContractError::CapExceeded { cap, total: new_supply }); }
}
let rcpt = deps.api.addr_validate(&recipient)?;
BALANCES.update(deps.storage, &rcpt, |bal: Option<Uint128>| -> StdResult<_> {
Ok(bal.unwrap_or_default() + amount)
})?;
TOKEN_INFO.update(deps.storage, |mut info| -> StdResult<_> {
info.total_supply = new_supply;
Ok(info)
})?;
Ok(Response::new()
.add_attribute("action", "mint")
.add_attribute("to", &recipient)
.add_attribute("amount", amount.to_string()))
}
fn execute_update_minter(deps: DepsMut, _env: Env, info: MessageInfo, new_minter: Option<String>) -> Result<Response, ContractError> {
let token_info = TOKEN_INFO.load(deps.storage)?;
let mint = token_info.mint.as_ref().ok_or(ContractError::NotMintable {})?;
if info.sender != mint.minter { return Err(ContractError::Unauthorized {}); }
let new_minter_addr = match &new_minter {
Some(addr) => Some(deps.api.addr_validate(addr)?),
None => None,
};
TOKEN_INFO.update(deps.storage, |mut info| -> StdResult<_> {
info.mint = Some(MinterData {
minter: new_minter_addr.clone().unwrap_or(mint.minter.clone()),
cap: mint.cap,
});
Ok(info)
})?;
Ok(Response::new()
.add_attribute("action", "update_minter")
.add_attribute("new_minter", new_minter.unwrap_or_default()))
}
fn check_allowance(storage: &dyn cosmwasm_std::Storage, owner: &Addr, spender: &Addr, amount: Uint128, height: u64, time: u64) -> Result<(), ContractError> {
let allowance = ALLOWANCES.may_load(storage, (owner, spender))?
.unwrap_or(Allowance { allowance: Uint128::zero(), expires: Expiration::Never {} });
if allowance.expires.is_expired(height, time) { return Err(ContractError::Expired {}); }
if allowance.allowance < amount { return Err(ContractError::InsufficientAllowance { allowance: allowance.allowance, required: amount }); }
Ok(())
}
fn execute_approve(deps: DepsMut, _env: Env, info: MessageInfo, spender: String, amount: Uint128, expires: Option<Expiration>) -> Result<Response, ContractError> {
let spender_addr = deps.api.addr_validate(&spender)?;
let expires = expires.unwrap_or(Expiration::Never {});
ALLOWANCES.save(deps.storage, (&info.sender, &spender_addr), &Allowance { allowance: amount, expires })?;
Ok(Response::new()
.add_attribute("action", "approve")
.add_attribute("owner", info.sender.as_str())
.add_attribute("spender", &spender)
.add_attribute("amount", amount.to_string()))
}
fn execute_increase_allowance(deps: DepsMut, _env: Env, info: MessageInfo, spender: String, amount: Uint128, expires: Option<Expiration>) -> Result<Response, ContractError> {
let spender_addr = deps.api.addr_validate(&spender)?;
let allowance = ALLOWANCES.may_load(deps.storage, (&info.sender, &spender_addr))?
.unwrap_or(Allowance { allowance: Uint128::zero(), expires: Expiration::Never {} });
let new_expires = expires.unwrap_or(allowance.expires);
ALLOWANCES.save(deps.storage, (&info.sender, &spender_addr), &Allowance {
allowance: allowance.allowance.checked_add(amount)?,
expires: new_expires,
})?;
Ok(Response::new()
.add_attribute("action", "increase_allowance")
.add_attribute("owner", info.sender.as_str())
.add_attribute("spender", &spender)
.add_attribute("amount", amount.to_string()))
}
fn execute_decrease_allowance(deps: DepsMut, _env: Env, info: MessageInfo, spender: String, amount: Uint128, expires: Option<Expiration>) -> Result<Response, ContractError> {
let spender_addr = deps.api.addr_validate(&spender)?;
let allowance = ALLOWANCES.may_load(deps.storage, (&info.sender, &spender_addr))?
.unwrap_or(Allowance { allowance: Uint128::zero(), expires: Expiration::Never {} });
let new_expires = expires.unwrap_or(allowance.expires);
ALLOWANCES.save(deps.storage, (&info.sender, &spender_addr), &Allowance {
allowance: allowance.allowance.checked_sub(amount)?,
expires: new_expires,
})?;
Ok(Response::new()
.add_attribute("action", "decrease_allowance")
.add_attribute("owner", info.sender.as_str())
.add_attribute("spender", &spender)
.add_attribute("amount", amount.to_string()))
}
fn execute_transfer_from(deps: DepsMut, env: Env, info: MessageInfo, owner: String, recipient: String, amount: Uint128) -> Result<Response, ContractError> {
let owner_addr = deps.api.addr_validate(&owner)?;
let rcpt = deps.api.addr_validate(&recipient)?;
let spender = &info.sender;
check_allowance(deps.storage, &owner_addr, spender, amount, env.block.height, env.block.time.seconds())?;
ALLOWANCES.update(deps.storage, (&owner_addr, spender), |allow| -> StdResult<_> {
let mut a = allow.unwrap_or(Allowance { allowance: Uint128::zero(), expires: Expiration::Never {} });
a.allowance = a.allowance.checked_sub(amount)?;
Ok(a)
})?;
transfer_balance(deps.storage, &owner_addr, &rcpt, amount)?;
Ok(Response::new()
.add_attribute("action", "transfer_from")
.add_attribute("from", &owner)
.add_attribute("to", &recipient)
.add_attribute("by", spender.as_str())
.add_attribute("amount", amount.to_string()))
}
fn execute_burn_from(deps: DepsMut, env: Env, info: MessageInfo, owner: String, amount: Uint128) -> Result<Response, ContractError> {
let owner_addr = deps.api.addr_validate(&owner)?;
let spender = &info.sender;
check_allowance(deps.storage, &owner_addr, spender, amount, env.block.height, env.block.time.seconds())?;
ALLOWANCES.update(deps.storage, (&owner_addr, spender), |allow| -> StdResult<_> {
let mut a = allow.unwrap_or(Allowance { allowance: Uint128::zero(), expires: Expiration::Never {} });
a.allowance = a.allowance.checked_sub(amount)?;
Ok(a)
})?;
BALANCES.update(deps.storage, &owner_addr, |bal: Option<Uint128>| -> StdResult<_> {
Ok(bal.unwrap_or_default().checked_sub(amount)?)
})?;
TOKEN_INFO.update(deps.storage, |mut info| -> StdResult<_> {
info.total_supply = info.total_supply.checked_sub(amount)?;
Ok(info)
})?;
Ok(Response::new()
.add_attribute("action", "burn_from")
.add_attribute("from", &owner)
.add_attribute("by", spender.as_str())
.add_attribute("amount", amount.to_string()))
}
fn execute_send(deps: DepsMut, _env: Env, info: MessageInfo, contract: String, amount: Uint128, msg: Binary) -> Result<Response, ContractError> {
let contract_addr = deps.api.addr_validate(&contract)?;
transfer_balance(deps.storage, &info.sender, &contract_addr, amount)?;
let send_msg = CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: contract,
msg: to_binary(&cw20::Cw20ReceiveMsg {
sender: info.sender.to_string(),
amount,
msg,
})?,
funds: vec![],
});
Ok(Response::new()
.add_message(send_msg)
.add_attribute("action", "send")
.add_attribute("from", info.sender.as_str())
.add_attribute("to", contract_addr.as_str())
.add_attribute("amount", amount.to_string()))
}
fn execute_send_from(deps: DepsMut, env: Env, info: MessageInfo, owner: String, contract: String, amount: Uint128, msg: Binary) -> Result<Response, ContractError> {
let owner_addr = deps.api.addr_validate(&owner)?;
let contract_addr = deps.api.addr_validate(&contract)?;
let spender = &info.sender;
check_allowance(deps.storage, &owner_addr, spender, amount, env.block.height, env.block.time.seconds())?;
ALLOWANCES.update(deps.storage, (&owner_addr, spender), |allow| -> StdResult<_> {
let mut a = allow.unwrap_or(Allowance { allowance: Uint128::zero(), expires: Expiration::Never {} });
a.allowance = a.allowance.checked_sub(amount)?;
Ok(a)
})?;
transfer_balance(deps.storage, &owner_addr, &contract_addr, amount)?;
let send_msg = CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: contract,
msg: to_binary(&cw20::Cw20ReceiveMsg { sender: owner, amount, msg })?,
funds: vec![],
});
Ok(Response::new()
.add_message(send_msg)
.add_attribute("action", "send_from")
.add_attribute("from", owner_addr.as_str())
.add_attribute("to", contract_addr.as_str())
.add_attribute("by", spender.as_str())
.add_attribute("amount", amount.to_string()))
}
fn execute_set_marketing(deps: DepsMut, _env: Env, info: MessageInfo, project: Option<String>, description: Option<String>, marketing: Option<String>) -> Result<Response, ContractError> {
crate::marketing::set_marketing(deps, info, project, description, marketing)
}
fn execute_upload_logo(deps: DepsMut, _env: Env, info: MessageInfo, logo: crate::msg::Logo) -> Result<Response, ContractError> {
crate::marketing::upload_logo(deps, info, logo)
}
#[entry_point]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::Balance { address } => to_binary(&query_balance(deps, address)?),
QueryMsg::TokenInfo {} => to_binary(&query_token_info(deps)?),
QueryMsg::Minter {} => to_binary(&query_minter(deps)?),
QueryMsg::Allowance { owner, spender } => to_binary(&query_allowance(deps, owner, spender)?),
QueryMsg::AllAccounts { start_after, limit } => to_binary(&query_all_accounts(deps, start_after, limit)?),
QueryMsg::AllAllowances { owner, start_after, limit } => to_binary(&query_all_allowances(deps, owner, start_after, limit)?),
QueryMsg::MarketingInfo {} => to_binary(&crate::marketing::query_marketing_info(deps)?),
QueryMsg::DownloadLogo {} => to_binary(&crate::marketing::query_download_logo(deps)?),
}
}
fn query_balance(deps: Deps, address: String) -> StdResult<BalanceResponse> {
let addr = deps.api.addr_validate(&address)?;
let balance = BALANCES.may_load(deps.storage, &addr)?.unwrap_or_default();
Ok(BalanceResponse { balance })
}
fn query_token_info(deps: Deps) -> StdResult<TokenInfoResponse> {
let info = TOKEN_INFO.load(deps.storage)?;
Ok(TokenInfoResponse { name: info.name, symbol: info.symbol, decimals: info.decimals, total_supply: info.total_supply })
}
fn query_minter(deps: Deps) -> StdResult<MinterResponse> {
let info = TOKEN_INFO.load(deps.storage)?;
match info.mint {
Some(m) => Ok(MinterResponse { minter: Some(m.minter.to_string()), cap: m.cap }),
None => Ok(MinterResponse { minter: None, cap: None }),
}
}
fn query_allowance(deps: Deps, owner: String, spender: String) -> StdResult<AllowanceResponse> {
let owner_addr = deps.api.addr_validate(&owner)?;
let spender_addr = deps.api.addr_validate(&spender)?;
let allowance = ALLOWANCES.may_load(deps.storage, (&owner_addr, &spender_addr))?
.unwrap_or(Allowance { allowance: Uint128::zero(), expires: Expiration::Never {} });
Ok(AllowanceResponse { allowance: allowance.allowance, expires: allowance.expires })
}
fn query_all_accounts(deps: Deps, start_after: Option<String>, limit: Option<u32>) -> StdResult<AllAccountsResponse> {
let limit = limit.unwrap_or(20).min(100) as usize;
let start = start_after.as_deref().map(|s| deps.api.addr_validate(s)).transpose()?;
let accounts: StdResult<Vec<_>> = BALANCES
.keys(deps.storage, None, None, cosmwasm_std::Order::Ascending)
.filter_map(|r| r.ok())
.skip(start.map(|s| s.as_str().len()).unwrap_or(0))
.take(limit)
.map(|a| Ok(a.to_string()))
.collect();
Ok(AllAccountsResponse { accounts: accounts? })
}
fn query_all_allowances(deps: Deps, owner: String, start_after: Option<String>, limit: Option<u32>) -> StdResult<AllAllowancesResponse> {
let limit = limit.unwrap_or(20).min(100) as usize;
let owner_addr = deps.api.addr_validate(&owner)?;
let allowances: StdResult<Vec<_>> = ALLOWANCES
.prefix(&owner_addr)
.range(deps.storage, None, None, cosmwasm_std::Order::Ascending)
.take(limit)
.map(|r| {
let (spender, allowance) = r?;
Ok(AllowanceInfo { owner: owner.clone(), spender: spender.to_string(), allowance: allowance.allowance, expires: allowance.expires })
})
.collect();
Ok(AllAllowancesResponse { allowances: allowances? })
}
2.7 单元测试
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
use cosmwasm_std::{from_binary, Addr};
fn setup_test_token(deps: DepsMut) {
let msg = InstantiateMsg {
name: "TestToken".to_string(),
symbol: "TST".to_string(),
decimals: 18,
initial_balances: vec![
Cw20Coin { address: "msg1alice".to_string(), amount: Uint128::new(1000) },
Cw20Coin { address: "msg1bob".to_string(), amount: Uint128::new(1000) },
],
mint: Some(MinterResponse { minter: "msg1minter".to_string(), cap: Some(Uint128::new(10000)) }),
marketing: None,
};
instantiate(deps, mock_env(), mock_info("creator", &[]), msg).unwrap();
}
#[test]
fn test_instantiate_basic() {
let mut deps = mock_dependencies();
let msg = InstantiateMsg {
name: "MyToken".to_string(), symbol: "MTK".to_string(), decimals: 18,
initial_balances: vec![], mint: None, marketing: None,
};
instantiate(deps.as_mut(), mock_env(), mock_info("creator", &[]), msg).unwrap();
let token_info = TOKEN_INFO.load(&deps.storage).unwrap();
assert_eq!(token_info.name, "MyToken");
assert_eq!(token_info.symbol, "MTK");
assert_eq!(token_info.decimals, 18);
assert_eq!(token_info.total_supply, Uint128::zero());
}
#[test]
fn test_instantiate_with_initial_balances() {
let mut deps = mock_dependencies();
let msg = InstantiateMsg {
name: "Token".to_string(), symbol: "TKN".to_string(), decimals: 6,
initial_balances: vec![
Cw20Coin { address: "msg1creator".to_string(), amount: Uint128::new(1000) },
Cw20Coin { address: "msg1user".to_string(), amount: Uint128::new(500) },
],
mint: Some(MinterResponse { minter: "msg1minter".to_string(), cap: Some(Uint128::new(10000)) }),
marketing: None,
};
instantiate(deps.as_mut(), mock_env(), mock_info("creator", &[]), msg).unwrap();
let token_info = TOKEN_INFO.load(&deps.storage).unwrap();
assert_eq!(token_info.total_supply, Uint128::new(1500));
let bal = BALANCES.load(&deps.storage, &Addr::unchecked("msg1user")).unwrap();
assert_eq!(bal, Uint128::new(500));
}
#[test]
fn test_transfer() {
let mut deps = mock_dependencies();
setup_test_token(deps.as_mut());
let msg = ExecuteMsg::Transfer { recipient: "msg1bob".to_string(), amount: Uint128::new(300) };
execute(deps.as_mut(), mock_env(), mock_info("msg1alice", &[]), msg).unwrap();
let alice_bal = BALANCES.load(&deps.storage, &Addr::unchecked("msg1alice")).unwrap();
assert_eq!(alice_bal, Uint128::new(700));
let bob_bal = BALANCES.load(&deps.storage, &Addr::unchecked("msg1bob")).unwrap();
assert_eq!(bob_bal, Uint128::new(1300));
}
#[test]
fn test_transfer_insufficient_balance() {
let mut deps = mock_dependencies();
setup_test_token(deps.as_mut());
let msg = ExecuteMsg::Transfer { recipient: "msg1bob".to_string(), amount: Uint128::new(2000) };
let err = execute(deps.as_mut(), mock_env(), mock_info("msg1alice", &[]), msg).unwrap_err();
assert!(matches!(err, ContractError::InsufficientBalance { .. }));
}
#[test]
fn test_approve_and_transfer_from() {
let mut deps = mock_dependencies();
let env = mock_env();
setup_test_token(deps.as_mut());
let info = mock_info("msg1alice", &[]);
execute(deps.as_mut(), env.clone(), info, ExecuteMsg::Approve {
spender: "msg1bob".to_string(), amount: Uint128::new(100), expires: None,
}).unwrap();
let allowance = ALLOWANCES.load(&deps.storage, (&Addr::unchecked("msg1alice"), &Addr::unchecked("msg1bob"))).unwrap();
assert_eq!(allowance.allowance, Uint128::new(100));
let info = mock_info("msg1bob", &[]);
execute(deps.as_mut(), env.clone(), info, ExecuteMsg::TransferFrom {
owner: "msg1alice".to_string(), recipient: "msg1charlie".to_string(), amount: Uint128::new(50),
}).unwrap();
let alice_bal = BALANCES.load(&deps.storage, &Addr::unchecked("msg1alice")).unwrap();
assert_eq!(alice_bal, Uint128::new(950));
let remaining = ALLOWANCES.load(&deps.storage, (&Addr::unchecked("msg1alice"), &Addr::unchecked("msg1bob"))).unwrap();
assert_eq!(remaining.allowance, Uint128::new(50));
}
#[test]
fn test_burn() {
let mut deps = mock_dependencies();
setup_test_token(deps.as_mut());
execute(deps.as_mut(), mock_env(), mock_info("msg1alice", &[]), ExecuteMsg::Burn { amount: Uint128::new(200) }).unwrap();
let alice_bal = BALANCES.load(&deps.storage, &Addr::unchecked("msg1alice")).unwrap();
assert_eq!(alice_bal, Uint128::new(800));
let token_info = TOKEN_INFO.load(&deps.storage).unwrap();
assert_eq!(token_info.total_supply, Uint128::new(1800));
}
#[test]
fn test_mint() {
let mut deps = mock_dependencies();
setup_test_token(deps.as_mut());
execute(deps.as_mut(), mock_env(), mock_info("msg1minter", &[]), ExecuteMsg::Mint {
recipient: "msg1dave".to_string(), amount: Uint128::new(500),
}).unwrap();
let dave_bal = BALANCES.load(&deps.storage, &Addr::unchecked("msg1dave")).unwrap();
assert_eq!(dave_bal, Uint128::new(500));
let token_info = TOKEN_INFO.load(&deps.storage).unwrap();
assert_eq!(token_info.total_supply, Uint128::new(2500));
}
#[test]
fn test_unauthorized_mint() {
let mut deps = mock_dependencies();
setup_test_token(deps.as_mut());
let err = execute(deps.as_mut(), mock_env(), mock_info("msg1not_minter", &[]), ExecuteMsg::Mint {
recipient: "msg1dave".to_string(), amount: Uint128::new(500),
}).unwrap_err();
assert_eq!(err, ContractError::Unauthorized {});
}
#[test]
fn test_cap_exceeded() {
let mut deps = mock_dependencies();
setup_test_token(deps.as_mut());
let err = execute(deps.as_mut(), mock_env(), mock_info("msg1minter", &[]), ExecuteMsg::Mint {
recipient: "msg1dave".to_string(), amount: Uint128::new(100000),
}).unwrap_err();
assert!(matches!(err, ContractError::CapExceeded { .. }));
}
#[test]
fn test_zero_amount_transfer_fails() {
let mut deps = mock_dependencies();
setup_test_token(deps.as_mut());
let err = execute(deps.as_mut(), mock_env(), mock_info("msg1alice", &[]), ExecuteMsg::Transfer {
recipient: "msg1bob".to_string(), amount: Uint128::zero(),
}).unwrap_err();
assert_eq!(err, ContractError::ZeroAmount {});
}
#[test]
fn test_approve_expired() {
let mut deps = mock_dependencies();
let mut env = mock_env();
env.block.height = 100;
setup_test_token(deps.as_mut());
execute(deps.as_mut(), env.clone(), mock_info("msg1alice", &[]), ExecuteMsg::Approve {
spender: "msg1bob".to_string(), amount: Uint128::new(100), expires: Some(Expiration::AtHeight(150)),
}).unwrap();
env.block.height = 200;
let err = execute(deps.as_mut(), env, mock_info("msg1bob", &[]), ExecuteMsg::TransferFrom {
owner: "msg1alice".to_string(), recipient: "msg1charlie".to_string(), amount: Uint128::new(50),
}).unwrap_err();
assert_eq!(err, ContractError::Expired {});
}
#[test]
fn test_increase_decrease_allowance() {
let mut deps = mock_dependencies();
let env = mock_env();
setup_test_token(deps.as_mut());
let info = mock_info("msg1alice", &[]);
execute(deps.as_mut(), env.clone(), info.clone(), ExecuteMsg::IncreaseAllowance {
spender: "msg1bob".to_string(), amount: Uint128::new(100), expires: None,
}).unwrap();
let allowance = ALLOWANCES.load(&deps.storage, (&Addr::unchecked("msg1alice"), &Addr::unchecked("msg1bob"))).unwrap();
assert_eq!(allowance.allowance, Uint128::new(100));
execute(deps.as_mut(), env, info, ExecuteMsg::DecreaseAllowance {
spender: "msg1bob".to_string(), amount: Uint128::new(30), expires: None,
}).unwrap();
let allowance = ALLOWANCES.load(&deps.storage, (&Addr::unchecked("msg1alice"), &Addr::unchecked("msg1bob"))).unwrap();
assert_eq!(allowance.allowance, Uint128::new(70));
}
#[test]
fn test_query_balance() {
let mut deps = mock_dependencies();
setup_test_token(deps.as_mut());
let bin = query(deps.as_ref(), mock_env(), QueryMsg::Balance { address: "msg1alice".to_string() }).unwrap();
let res: BalanceResponse = from_binary(&bin).unwrap();
assert_eq!(res.balance, Uint128::new(1000));
}
#[test]
fn test_query_token_info() {
let mut deps = mock_dependencies();
setup_test_token(deps.as_mut());
let bin = query(deps.as_ref(), mock_env(), QueryMsg::TokenInfo {}).unwrap();
let res: TokenInfoResponse = from_binary(&bin).unwrap();
assert_eq!(res.name, "TestToken");
assert_eq!(res.symbol, "TST");
assert_eq!(res.decimals, 18);
assert_eq!(res.total_supply, Uint128::new(2000));
}
}
2.8 src/lib.rs
pub mod contract;
pub mod error;
pub mod msg;
pub mod state;
pub mod allowances;
pub mod enumerable;
pub mod marketing;
pub use crate::error::ContractError;
2.9 src/enumerable.rs
use cosmwasm_std::{Deps, StdResult, Storage, Addr, Order};
use crate::state::{BALANCES, ALLOWANCES, Allowance};
use crate::msg::{AllAccountsResponse, AllAllowancesResponse, AllowanceInfo};
pub fn query_all_accounts(storage: &dyn Storage, start_after: Option<Addr>, limit: usize) -> StdResult<AllAccountsResponse> {
let accounts: StdResult<Vec<_>> = BALANCES
.keys(storage, None, None, Order::Ascending)
.filter_map(|r| r.ok())
.skip(start_after.map(|s| s.as_str().len()).unwrap_or(0))
.take(limit)
.map(|a| Ok(a.to_string()))
.collect();
Ok(AllAccountsResponse { accounts: accounts? })
}
pub fn query_all_allowances(storage: &dyn Storage, owner: &Addr, start_after: Option<Addr>, limit: usize) -> StdResult<AllAllowancesResponse> {
let allowances: StdResult<Vec<_>> = ALLOWANCES
.prefix(owner)
.range(storage, None, None, Order::Ascending)
.skip(start_after.map(|s| s.as_str().len()).unwrap_or(0))
.take(limit)
.map(|r| {
let (spender, allowance) = r?;
Ok(AllowanceInfo { owner: owner.to_string(), spender: spender.to_string(), allowance: allowance.allowance, expires: allowance.expires })
})
.collect();
Ok(AllAllowancesResponse { allowances: allowances? })
}
2.10 src/marketing.rs
use cosmwasm_std::{Deps, DepsMut, MessageInfo, Response, StdResult, Binary};
use cw_storage_plus::Item;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::ContractError;
use crate::msg::{Logo, LogoInfo, MarketingInfoResponse};
const LOGO_SIZE_CAP: u64 = 5 * 1024;
const LOGO_URL_CAP: u64 = 2048;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct MarketingInfo {
pub project: Option<String>,
pub description: Option<String>,
pub marketing: Option<String>,
pub logo: Option<LogoInfo>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum LogoStore { Url(String), Embedded(Binary) }
pub const MARKETING_INFO: Item<MarketingInfo> = Item::new("marketing_info");
pub const LOGO: Item<LogoStore> = Item::new("logo");
pub fn set_marketing(deps: DepsMut, info: MessageInfo, project: Option<String>, description: Option<String>, marketing: Option<String>) -> Result<Response, ContractError> {
let mut marketing_info = MARKETING_INFO.may_load(deps.storage)?.unwrap_or(MarketingInfo {
project: None, description: None, marketing: None, logo: None,
});
if marketing_info.marketing.is_some() {
if Some(info.sender.to_string()) != marketing_info.marketing {
return Err(ContractError::Unauthorized {});
}
}
if let Some(project) = project { marketing_info.project = Some(project); }
if let Some(description) = description { marketing_info.description = Some(description); }
if let Some(marketing) = marketing { marketing_info.marketing = Some(marketing); }
MARKETING_INFO.save(deps.storage, &marketing_info)?;
Ok(Response::new().add_attribute("action", "set_marketing"))
}
pub fn upload_logo(deps: DepsMut, info: MessageInfo, logo: Logo) -> Result<Response, ContractError> {
let marketing_info = MARKETING_INFO.may_load(deps.storage)?.unwrap_or(MarketingInfo {
project: None, description: None, marketing: None, logo: None,
});
if let Some(ref marketing) = marketing_info.marketing {
if info.sender.to_string() != *marketing { return Err(ContractError::Unauthorized {}); }
}
match logo {
Logo::Url(url) => {
if url.len() as u64 > LOGO_URL_CAP { return Err(ContractError::Std(cosmwasm_std::StdError::generic_err("URL too long"))); }
LOGO.save(deps.storage, &LogoStore::Url(url.clone()))?;
}
Logo::Embedded(binary) => {
if binary.len() as u64 > LOGO_SIZE_CAP { return Err(ContractError::Std(cosmwasm_std::StdError::generic_err("Logo too large"))); }
LOGO.save(deps.storage, &LogoStore::Embedded(binary.clone()))?;
}
}
Ok(Response::new().add_attribute("action", "upload_logo"))
}
pub fn query_marketing_info(deps: Deps) -> StdResult<MarketingInfoResponse> {
let info = MARKETING_INFO.may_load(deps.storage)?.unwrap_or(MarketingInfo {
project: None, description: None, marketing: None, logo: None,
});
Ok(MarketingInfoResponse { project: info.project, description: info.description, marketing: info.marketing, logo: info.logo })
}
pub fn query_download_logo(deps: Deps) -> StdResult<LogoInfo> {
let logo = LOGO.may_load(deps.storage)?;
match logo {
Some(LogoStore::Url(url)) => Ok(LogoInfo::Url(url)),
Some(LogoStore::Embedded(_)) => Ok(LogoInfo::Embedded),
None => Err(cosmwasm_std::StdError::not_found("logo")),
}
}
2.11 examples/schema.rs
use cosmwasm_schema::write_api;
use cw20_base::msg::{ExecuteMsg, InstantiateMsg, QueryMsg};
fn main() {
write_api! {
instantiate: InstantiateMsg,
execute: ExecuteMsg,
query: QueryMsg,
}
}
3. CW20 工厂合约
3.1 概述
工厂合约(Factory Contract)用于一键部署新的 CW20 代币。用户只需调用一次 create_token,合约自动实例化新的 cw20_base 实例并追踪所有已创建的代币。
3.2 项目结构
token-factory/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── contract.rs
│ ├── msg.rs
│ ├── state.rs
│ └── error.rs
├── examples/
│ └── schema.rs
├── tests/
│ └── integration.rs
└── schema/
3.3 Cargo.toml
[package]
name = "token-factory"
version = "1.0.0"
edition = "2021"
description = "MSG Chain CW20 Token Factory - deploy new tokens with one call"
[lib]
crate-type = ["cdylib", "rlib"]
[profile.release]
opt-level = 3
debug = false
rpath = false
lto = true
debug-assertions = false
codegen-units = 1
panic = "abort"
incremental = false
overflow-checks = true
[dependencies]
cosmwasm-std = "1.5"
cw-storage-plus = "1.2"
cw2 = "1.1"
cw20 = "1.1"
cw-utils = "1.0"
schemars = "0.8"
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
uint = "0.9"
[dev-dependencies]
cw-multi-test = "0.18"
anyhow = "1.0"
3.4 src/state.rs
use cosmwasm_schema::cw_serde;
use cosmwasm_std::{Addr, Coin, Uint128};
use cw_storage_plus::{Item, Map};
#[cw_serde]
pub struct Config {
pub token_code_id: u64,
pub creation_fee: Option<Coin>,
pub owner: Addr,
pub fee_collector: Option<Addr>,
}
#[cw_serde]
pub struct TokenInfo {
pub contract_addr: Addr,
pub name: String,
pub symbol: String,
pub decimals: u8,
pub creator: Addr,
pub created_at: u64,
pub total_supply: Uint128,
pub is_mintable: bool,
}
pub const CONFIG: Item<Config> = Item::new("config");
pub const TOKEN_LIST: Map<u64, TokenInfo> = Map::new("token_list");
pub const TOKEN_COUNT: Item<u64> = Item::new("token_count");
pub const TOKEN_BY_ADDRESS: Map<&Addr, u64> = Map::new("token_by_addr");
pub const CREATOR_TOKENS: Map<(&Addr, u64), bool> = Map::new("creator_tokens");
3.5 src/msg.rs
use cosmwasm_schema::cw_serde;
use cosmwasm_std::{Coin, Uint128};
#[cw_serde]
pub struct InstantiateMsg {
pub token_code_id: u64,
pub creation_fee: Option<Coin>,
pub fee_collector: Option<String>,
}
#[cw_serde]
pub enum ExecuteMsg {
CreateToken {
name: String,
symbol: String,
decimals: u8,
initial_balances: Vec<Cw20Coin>,
minter: Option<String>,
cap: Option<Uint128>,
},
UpdateConfig {
token_code_id: Option<u64>,
creation_fee: Option<Option<Coin>>,
fee_collector: Option<Option<String>>,
owner: Option<String>,
},
WithdrawFees {
amount: Option<Coin>,
recipient: Option<String>,
},
}
#[cw_serde]
pub struct Cw20Coin {
pub address: String,
pub amount: Uint128,
}
#[cw_serde]
pub enum QueryMsg {
Config {},
ListTokens { start_after: Option<u64>, limit: Option<u32> },
TokenCount {},
GetToken { contract_addr: String },
GetTokensByCreator { creator: String, start_after: Option<u64>, limit: Option<u32> },
FeeBalance {},
}
#[cw_serde]
pub struct ConfigResponse {
pub token_code_id: u64,
pub creation_fee: Option<Coin>,
pub owner: String,
pub fee_collector: Option<String>,
}
#[cw_serde]
pub struct ListTokensResponse { pub tokens: Vec<TokenInfoResponse> }
#[cw_serde]
pub struct TokenInfoResponse {
pub index: u64,
pub contract_addr: String,
pub name: String,
pub symbol: String,
pub decimals: u8,
pub creator: String,
pub created_at: u64,
pub total_supply: Uint128,
pub is_mintable: bool,
}
#[cw_serde]
pub struct TokenCountResponse { pub count: u64 }
#[cw_serde]
pub struct FeeBalanceResponse { pub balance: Vec<Coin> }
3.6 src/error.rs
use cosmwasm_std::{StdError, Coin};
use thiserror::Error;
#[derive(Error, Debug, PartialEq)]
pub enum ContractError {
#[error("{0}")] Std(#[from] StdError),
#[error("Unauthorized")] Unauthorized {},
#[error("Insufficient fee: provided {provided}, required {required}")]
InsufficientFee { provided: String, required: String },
#[error("Token code ID not set")] TokenCodeIdNotSet {},
#[error("Invalid reply from token instantiation")] InvalidReply {},
#[error("Token already registered")] TokenAlreadyRegistered {},
#[error("No fees to withdraw")] NoFees {},
#[error("Invalid decimals: must be between 0 and 18")] InvalidDecimals {},
#[error("Token not found")] TokenNotFound {},
}
3.7 src/contract.rs
use cosmwasm_std::{
entry_point, to_binary, BankMsg, Binary, Coin, CosmosMsg, Deps, DepsMut,
Env, MessageInfo, Order, Reply, Response, StdError, StdResult, SubMsg,
Uint128, WasmMsg,
};
use cw2::set_contract_version;
use cw20::MinterResponse;
use crate::error::ContractError;
use crate::msg::{
ConfigResponse, Cw20Coin, ExecuteMsg, FeeBalanceResponse,
InstantiateMsg, ListTokensResponse, QueryMsg, TokenCountResponse,
TokenInfoResponse,
};
use crate::state::{
Config, TokenInfo, CONFIG, CREATOR_TOKENS, TOKEN_BY_ADDRESS,
TOKEN_COUNT, TOKEN_LIST,
};
const CONTRACT_NAME: &str = "cw20-token-factory";
const CONTRACT_VERSION: &str = "1.0.0";
const REPLY_CREATE_TOKEN: u64 = 1;
#[entry_point]
pub fn instantiate(deps: DepsMut, _env: Env, info: MessageInfo, msg: InstantiateMsg) -> StdResult<Response> {
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
let fee_collector = match msg.fee_collector {
Some(addr) => Some(deps.api.addr_validate(&addr)?),
None => None,
};
let config = Config {
token_code_id: msg.token_code_id,
creation_fee: msg.creation_fee,
owner: info.sender,
fee_collector,
};
CONFIG.save(deps.storage, &config)?;
TOKEN_COUNT.save(deps.storage, &0u64)?;
Ok(Response::new()
.add_attribute("method", "instantiate")
.add_attribute("token_code_id", config.token_code_id.to_string())
.add_attribute("owner", info.sender.as_str()))
}
#[entry_point]
pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> Result<Response, ContractError> {
match msg {
ExecuteMsg::CreateToken { name, symbol, decimals, initial_balances, minter, cap } => {
execute_create_token(deps, env, info, name, symbol, decimals, initial_balances, minter, cap)
}
ExecuteMsg::UpdateConfig { token_code_id, creation_fee, fee_collector, owner } => {
execute_update_config(deps, info, token_code_id, creation_fee, fee_collector, owner)
}
ExecuteMsg::WithdrawFees { amount, recipient } => {
execute_withdraw_fees(deps, env, info, amount, recipient)
}
}
}
pub fn execute_create_token(
deps: DepsMut, env: Env, info: MessageInfo,
name: String, symbol: String, decimals: u8,
initial_balances: Vec<Cw20Coin>, minter: Option<String>, cap: Option<Uint128>,
) -> Result<Response, ContractError> {
if decimals > 18 { return Err(ContractError::InvalidDecimals {}); }
let config = CONFIG.load(deps.storage)?;
if let Some(ref fee) = config.creation_fee {
let provided = info.funds.iter()
.find(|c| c.denom == fee.denom)
.map(|c| c.amount)
.unwrap_or(Uint128::zero());
if provided < fee.amount {
return Err(ContractError::InsufficientFee {
provided: format!("{}{}", provided, fee.denom),
required: format!("{}{}", fee.amount, fee.denom),
});
}
}
let cw20_init_msg = cw20_base::msg::InstantiateMsg {
name: name.clone(),
symbol: symbol.clone(),
decimals,
initial_balances: initial_balances.into_iter()
.map(|c| cw20_base::msg::Cw20Coin { address: c.address, amount: c.amount })
.collect(),
mint: minter.map(|m| MinterResponse { minter: m, cap }),
marketing: None,
};
let label = format!("CW20-{}-{}", name, symbol);
let instantiate_msg = WasmMsg::Instantiate {
admin: Some(config.owner.to_string()),
code_id: config.token_code_id,
msg: to_binary(&cw20_init_msg)?,
funds: vec![],
label,
};
cw_storage_plus::Item::<PendingTokenInfo>::new("pending_token").save(
deps.storage,
&PendingTokenInfo {
name, symbol, decimals,
creator: info.sender,
created_at: env.block.time.seconds(),
is_mintable: minter.is_some(),
},
)?;
Ok(Response::new()
.add_submessage(SubMsg::reply_on_success(instantiate_msg, REPLY_CREATE_TOKEN))
.add_attribute("method", "create_token")
.add_attribute("name", &name)
.add_attribute("symbol", &symbol))
}
fn execute_update_config(
deps: DepsMut, info: MessageInfo,
token_code_id: Option<u64>, creation_fee: Option<Option<Coin>>,
fee_collector: Option<Option<String>>, new_owner: Option<String>,
) -> Result<Response, ContractError> {
let mut config = CONFIG.load(deps.storage)?;
if info.sender != config.owner { return Err(ContractError::Unauthorized {}); }
if let Some(code_id) = token_code_id { config.token_code_id = code_id; }
if let Some(fee) = creation_fee { config.creation_fee = fee; }
if let Some(collector) = fee_collector {
config.fee_collector = match collector {
Some(addr) => Some(deps.api.addr_validate(&addr)?),
None => None,
};
}
if let Some(owner) = new_owner { config.owner = deps.api.addr_validate(&owner)?; }
CONFIG.save(deps.storage, &config)?;
Ok(Response::new().add_attribute("method", "update_config"))
}
fn execute_withdraw_fees(deps: DepsMut, env: Env, info: MessageInfo, amount: Option<Coin>, recipient: Option<String>) -> Result<Response, ContractError> {
let config = CONFIG.load(deps.storage)?;
let is_authorized = info.sender == config.owner || Some(info.sender.clone()) == config.fee_collector;
if !is_authorized { return Err(ContractError::Unauthorized {}); }
let recipient = match recipient {
Some(addr) => deps.api.addr_validate(&addr)?,
None => config.fee_collector.unwrap_or(config.owner),
};
let balance = deps.querier.query_all_balances(&env.contract.address)?;
if balance.is_empty() { return Err(ContractError::NoFees {}); }
let send_coins = match amount {
Some(coin) => vec![coin],
None => balance,
};
let send_msg = CosmosMsg::Bank(BankMsg::Send { to_address: recipient.to_string(), assets: send_coins.clone() });
Ok(Response::new()
.add_message(send_msg)
.add_attribute("method", "withdraw_fees"))
}
#[cw_serde]
pub struct PendingTokenInfo {
pub name: String, pub symbol: String, pub decimals: u8,
pub creator: Addr, pub created_at: u64, pub is_mintable: bool,
}
#[entry_point]
pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result<Response, ContractError> {
match msg.id {
REPLY_CREATE_TOKEN => handle_create_token_reply(deps, msg),
_ => Err(ContractError::InvalidReply {}),
}
}
fn handle_create_token_reply(deps: DepsMut, msg: Reply) -> Result<Response, ContractError> {
let token_contract = msg.result.into_result()
.map_err(|e| ContractError::Std(StdError::generic_err(e)))?
.get_contract_address()
.ok_or(ContractError::InvalidReply {})?;
let token_addr = deps.api.addr_validate(&token_contract)?;
if TOKEN_BY_ADDRESS.may_load(deps.storage, &token_addr)?.is_some() {
return Err(ContractError::TokenAlreadyRegistered {});
}
let pending: PendingTokenInfo = cw_storage_plus::Item::new("pending_token").load(deps.storage)?;
let token_info: cw20::TokenInfoResponse = deps.querier.query_wasm_smart(
&token_addr, &cw20::Cw20QueryMsg::TokenInfo {}
)?;
let index = TOKEN_COUNT.load(deps.storage)?;
let record = TokenInfo {
contract_addr: token_addr.clone(), name: pending.name, symbol: pending.symbol,
decimals: pending.decimals, creator: pending.creator.clone(),
created_at: pending.created_at, total_supply: token_info.total_supply,
is_mintable: pending.is_mintable,
};
TOKEN_LIST.save(deps.storage, &index, &record)?;
TOKEN_BY_ADDRESS.save(deps.storage, &token_addr, &index)?;
CREATOR_TOKENS.save(deps.storage, (&pending.creator, index), &true)?;
TOKEN_COUNT.save(deps.storage, &(index + 1))?;
Ok(Response::new()
.add_attribute("method", "handle_create_token_reply")
.add_attribute("token_address", token_contract)
.add_attribute("token_index", index.to_string()))
}
#[entry_point]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::Config {} => to_binary(&query_config(deps)?),
QueryMsg::ListTokens { start_after, limit } => to_binary(&query_list_tokens(deps, start_after, limit)?),
QueryMsg::TokenCount {} => to_binary(&query_token_count(deps)?),
QueryMsg::GetToken { contract_addr } => to_binary(&query_get_token(deps, contract_addr)?),
QueryMsg::GetTokensByCreator { creator, start_after, limit } => to_binary(&query_tokens_by_creator(deps, creator, start_after, limit)?),
QueryMsg::FeeBalance {} => to_binary(&query_fee_balance(deps, env)?),
}
}
fn query_config(deps: Deps) -> StdResult<ConfigResponse> {
let config = CONFIG.load(deps.storage)?;
Ok(ConfigResponse {
token_code_id: config.token_code_id,
creation_fee: config.creation_fee,
owner: config.owner.to_string(),
fee_collector: config.fee_collector.map(|a| a.to_string()),
})
}
fn query_list_tokens(deps: Deps, start_after: Option<u64>, limit: Option<u32>) -> StdResult<ListTokensResponse> {
let limit = limit.unwrap_or(30).min(100) as usize;
let start = start_after.unwrap_or(0);
let tokens: StdResult<Vec<_>> = TOKEN_LIST
.range(deps.storage, Some(start.into()), None, Order::Ascending)
.take(limit)
.map(|r| {
let (index, info) = r?;
Ok(TokenInfoResponse {
index, contract_addr: info.contract_addr.to_string(),
name: info.name, symbol: info.symbol, decimals: info.decimals,
creator: info.creator.to_string(), created_at: info.created_at,
total_supply: info.total_supply, is_mintable: info.is_mintable,
})
})
.collect();
Ok(ListTokensResponse { tokens: tokens? })
}
fn query_token_count(deps: Deps) -> StdResult<TokenCountResponse> {
Ok(TokenCountResponse { count: TOKEN_COUNT.load(deps.storage)? })
}
fn query_get_token(deps: Deps, contract_addr: String) -> StdResult<TokenInfoResponse> {
let addr = deps.api.addr_validate(&contract_addr)?;
let index = TOKEN_BY_ADDRESS.may_load(deps.storage, &addr)?
.ok_or_else(|| StdError::not_found("Token not found"))?;
let info = TOKEN_LIST.load(deps.storage, &index)?;
Ok(TokenInfoResponse {
index, contract_addr: info.contract_addr.to_string(),
name: info.name, symbol: info.symbol, decimals: info.decimals,
creator: info.creator.to_string(), created_at: info.created_at,
total_supply: info.total_supply, is_mintable: info.is_mintable,
})
}
fn query_tokens_by_creator(deps: Deps, creator: String, start_after: Option<u64>, limit: Option<u32>) -> StdResult<ListTokensResponse> {
let creator_addr = deps.api.addr_validate(&creator)?;
let limit = limit.unwrap_or(30).min(100) as usize;
let start = start_after.unwrap_or(0);
let tokens: StdResult<Vec<_>> = CREATOR_TOKENS
.prefix(&creator_addr)
.range(deps.storage, Some(start.into()), None, Order::Ascending)
.take(limit)
.map(|r| {
let (index, _) = r?;
let info = TOKEN_LIST.load(deps.storage, &index)?;
Ok(TokenInfoResponse {
index, contract_addr: info.contract_addr.to_string(),
name: info.name, symbol: info.symbol, decimals: info.decimals,
creator: info.creator.to_string(), created_at: info.created_at,
total_supply: info.total_supply, is_mintable: info.is_mintable,
})
})
.collect();
Ok(ListTokensResponse { tokens: tokens? })
}
fn query_fee_balance(deps: Deps, env: Env) -> StdResult<FeeBalanceResponse> {
let config = CONFIG.load(deps.storage)?;
let fee_denom = match &config.creation_fee {
Some(fee) => fee.denom.clone(),
None => "umsg".to_string(),
};
let balance = deps.querier.query_balance(&env.contract.address, &fee_denom)?;
Ok(FeeBalanceResponse { balance: vec![balance] })
}
3.8 src/lib.rs
pub mod contract;
pub mod error;
pub mod msg;
pub mod state;
pub use crate::error::ContractError;
4. 代币部署
4.1 使用 msg-chain-devkit CLI
4.1.1 编译 cw20_base 合约
cd contracts/cosmwasm/all/cw20-base
cargo build --target wasm32-unknown-unknown --release
cd ../..
# 使用 devkit 编译所有合约
msg-devkit compile
# 优化 WASM
wasm-opt -Os contracts/cosmwasm/all/cw20-base/target/wasm32-unknown-unknown/release/cw20_base.wasm \
-o artifacts/cw20_base.wasm
4.1.2 存储合约代码
# 存储 cw20_base.wasm
CODE_ID=$(msgd tx wasm store artifacts/cw20_base.wasm \
--from my-key \
--gas auto --gas-adjustment 1.3 \
--gas-prices 1000000000attoMSG \
--chain-id msg-chain-1 \
--node tcp://localhost:26657 \
-y --output json | jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "cw20_base Code ID: $CODE_ID"
# 验证
msgd query wasm code $CODE_ID --node tcp://localhost:26657
4.1.3 直接部署 CW20 代币
CODE_ID=1 # 上一步获取的 Code ID
MSG='{"name":"MyToken","symbol":"MTK","decimals":18,"initial_balances":[{"address":"msg1alice","amount":"1000000000000000000000000"}],"mint":{"minter":"msg1minter","cap":"10000000000000000000000000000"}}'
msgd tx wasm instantiate $CODE_ID "$MSG" \
--label "MyToken-v1" \
--admin $(msgd keys show my-key -a) \
--from my-key \
--gas auto --gas-prices 1000000000attoMSG \
--chain-id msg-chain-1 \
--node tcp://localhost:26657 -y
# 使用 devkit
msg-devkit deploy \
--network local \
--contract cw20_base \
--label "MyToken-v1" \
--msg "$MSG"
4.1.4 部署 Factory 合约
# 先存储 factory.wasm
FACTORY_CODE_ID=$(msgd tx wasm store artifacts/token_factory.wasm \
--from my-key --gas auto --gas-adjustment 1.3 \
--gas-prices 1000000000attoMSG --chain-id msg-chain-1 -y --output json \
| jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
# 实例化 Factory
TOKEN_CODE_ID=1 # cw20_base 的 Code ID
MSG='{"token_code_id":1,"creation_fee":{"denom":"umsg","amount":"1000000000000000000"},"fee_collector":"msg1fee_collector"}'
msgd tx wasm instantiate $FACTORY_CODE_ID "$MSG" \
--label "cw20-token-factory-v1" \
--admin $(msgd keys show my-key -a) \
--from my-key \
--gas auto --gas-prices 1000000000attoMSG \
--chain-id msg-chain-1 -y
# 通过 devkit
msg-devkit deploy \
--network local \
--contract token-factory \
--admin $(msgd keys show my-key -a) \
--label "cw20-factory-v1" \
--msg "$MSG"
4.1.5 通过 Factory 创建代币
FACTORY_ADDR="msg1factory_address_here"
MSG='{"create_token":{"name":"FactoryToken","symbol":"FTK","decimals":18,"initial_balances":[{"address":"msg1recipient","amount":"1000000000000000000000000"}],"minter":"msg1minter","cap":"10000000000000000000000000000"}}'
# 如果需要创建费用
FEE='--amount 1000000000000000000umsg'
msgd tx wasm execute $FACTORY_ADDR "$MSG" \
--from my-key \
--gas auto --gas-prices 1000000000attoMSG \
--chain-id msg-chain-1 -y $FEE
# 查询工厂状态
msgd query wasm contract-state smart $FACTORY_ADDR '{"token_count":{}}'
msgd query wasm contract-state smart $FACTORY_ADDR '{"list_tokens":{"limit":10}}'
4.2 使用 TypeScript / CosmJS
4.2.1 客户端设置
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";
import { GasPrice } from "@cosmjs/stargate";
async function setupClient(): Promise<{ client: SigningCosmWasmClient; address: string }> {
const mnemonic = process.env.MSG_MNEMONIC || "";
const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { prefix: "msg" });
const client = await SigningCosmWasmClient.connectWithSigner(
"http://localhost:26657", wallet,
{ gasPrice: GasPrice.fromString("1000000000attoMSG") }
);
const [account] = await wallet.getAccounts();
return { client, address: account.address };
}
4.2.2 部署 CW20 代币(TypeScript)
interface Cw20Coin { address: string; amount: string }
interface Cw20InstantiateMsg {
name: string; symbol: string; decimals: number;
initial_balances: Cw20Coin[];
mint: { minter: string; cap?: string } | null;
}
async function deployCw20Token(
client: SigningCosmWasmClient, deployer: string, codeId: number
): Promise<string> {
const msg: Cw20InstantiateMsg = {
name: "MyToken", symbol: "MTK", decimals: 18,
initial_balances: [{ address: deployer, amount: "1000000000000000000000000" }],
mint: { minter: "msg1minter", cap: "10000000000000000000000000000" },
};
const result = await client.instantiate(deployer, codeId, msg, "MyToken-v1", {
admin: deployer, label: "MyToken",
});
console.log(`Token: ${result.contractAddress}`);
return result.contractAddress;
}
4.2.3 部署 Factory(TypeScript)
async function deployFactory(
client: SigningCosmWasmClient, deployer: string,
factoryCodeId: number, tokenCodeId: number
): Promise<string> {
const msg = {
token_code_id: tokenCodeId,
creation_fee: { denom: "umsg", amount: "1000000000000000000" },
fee_collector: "msg1collector",
};
const result = await client.instantiate(deployer, factoryCodeId, msg, "CW20-Factory", {
admin: deployer, label: "factory-v1",
});
return result.contractAddress;
}
4.2.4 通过 Factory 创建代币并解析事件
async function createTokenViaFactory(
client: SigningCosmWasmClient, sender: string, factoryAddress: string
): Promise<string> {
const msg = {
create_token: {
name: "FTK", symbol: "FTK", decimals: 18,
initial_balances: [{ address: sender, amount: "500000000000000000000000" }],
minter: null, cap: null,
},
};
const funds = [{ denom: "umsg", amount: "1000000000000000000" }];
const result = await client.execute(sender, factoryAddress, msg, "auto", undefined, funds);
// 从交易事件中解析代币地址
const tokenAddress = result.events
.find((e) => e.type === "wasm")
?.attributes.find((a) => a.key === "token_address")?.value;
return tokenAddress || "";
}
4.2.5 查询代币(TypeScript)
async function queryToken(client: SigningCosmWasmClient, tokenAddress: string, address: string) {
const info: any = await client.queryContractSmart(tokenAddress, { token_info: {} });
console.log(`Name: ${info.name}, Symbol: ${info.symbol}, Supply: ${info.total_supply}`);
const balance: any = await client.queryContractSmart(tokenAddress, { balance: { address } });
console.log(`Balance of ${address}: ${balance.balance}`);
const minter: any = await client.queryContractSmart(tokenAddress, { minter: {} });
console.log(`Minter: ${minter.minter}, Cap: ${minter.cap}`);
}
4.3 使用 Python SDK
4.3.1 客户端设置
import asyncio, json
from cosmipy import CosmWasmClient, Wallet
from cosmipy.models import Coin
async def setup_client():
wallet = Wallet.from_mnemonic("your mnemonic", prefix="msg")
client = await CosmWasmClient.connect(
rpc_url="http://localhost:26657",
wallet=wallet,
gas_price=Coin(amount=25000000000000, denom="umsg"),
)
return client, wallet.address
4.3.2 部署代币
async def deploy_token(client, deployer: str, code_id: int) -> str:
msg = {
"name": "MyToken", "symbol": "MTK", "decimals": 18,
"initial_balances": [{"address": deployer, "amount": "1000000000000000000000000"}],
"mint": {"minter": deployer, "cap": "10000000000000000000000000000"}
}
result = await client.instantiate(
sender=deployer, code_id=code_id,
msg=json.dumps(msg).encode(),
label="MyToken-v1", admin=deployer,
)
print(f"Token deployed: {result.contract_address}")
return result.contract_address
async def query_token(client, token_address: str):
info = await client.query_contract_smart(token_address, {"token_info": {}})
balance = await client.query_contract_smart(
token_address, {"balance": {"address": "msg1alice"}}
)
return info, balance
4.3.3 批量部署
async def batch_deploy(client, deployer: str, code_id: int, count: int):
addresses = []
for i in range(count):
msg = {
"name": f"BatchToken{i}", "symbol": f"BT{i}", "decimals": 18,
"initial_balances": [{"address": deployer, "amount": "1000000000000000000000000"}],
"mint": None,
}
result = await client.instantiate(
sender=deployer, code_id=code_id,
msg=json.dumps(msg).encode(),
label=f"BatchToken{i}", admin=deployer,
)
addresses.append(result.contract_address)
return addresses
5. 代币操作指南
5.1 CLI 操作
转账
TOKEN_ADDR="msg1token_address"
# 转账 100 个代币(18位精度 => 100 * 10^18)
msgd tx wasm execute $TOKEN_ADDR \
'{"transfer":{"recipient":"msg1recipient","amount":"100000000000000000000"}}' \
--from my-key --gas auto --gas-prices 1000000000attoMSG --chain-id msg-chain-1 -y
授权 + TransferFrom
# Alice 授权 Bob 100 个代币
msgd tx wasm execute $TOKEN_ADDR \
'{"approve":{"spender":"msg1bob","amount":"100000000000000000000"}}' \
--from msg1alice --gas auto --gas-prices 1000000000attoMSG --chain-id msg-chain-1 -y
# Bob 从 Alice 转账 50 个给 Charlie
msgd tx wasm execute $TOKEN_ADDR \
'{"transfer_from":{"owner":"msg1alice","recipient":"msg1charlie","amount":"50000000000000000000"}}' \
--from msg1bob --gas auto --gas-prices 1000000000attoMSG --chain-id msg-chain-1 -y
铸造
msgd tx wasm execute $TOKEN_ADDR \
'{"mint":{"recipient":"msg1recipient","amount":"1000000000000000000000000"}}' \
--from msg1minter --gas auto --gas-prices 1000000000attoMSG --chain-id msg-chain-1 -y
销毁
msgd tx wasm execute $TOKEN_ADDR \
'{"burn":{"amount":"10000000000000000000"}}' \
--from my-key --gas auto --gas-prices 1000000000attoMSG --chain-id msg-chain-1 -y
营销信息
msgd tx wasm execute $TOKEN_ADDR \
'{"set_marketing":{"project":"MyToken","description":"A great token","marketing":"msg1marketing"}}' \
--from msg1marketing --gas auto --gas-prices 1000000000attoMSG --chain-id msg-chain-1 -y
查询
# 代币信息
msgd query wasm contract-state smart $TOKEN_ADDR '{"token_info":{}}'
# 余额
msgd query wasm contract-state smart $TOKEN_ADDR '{"balance":{"address":"msg1alice"}}'
# 授权
msgd query wasm contract-state smart $TOKEN_ADDR '{"allowance":{"owner":"msg1alice","spender":"msg1bob"}}'
# minter
msgd query wasm contract-state smart $TOKEN_ADDR '{"minter":{}}'
5.2 TypeScript 操作
async function transfer(client: SigningCosmWasmClient, sender: string, token: string, to: string, amount: string) {
return client.execute(sender, token, { transfer: { recipient: to, amount } }, "auto");
}
async function approve(client: SigningCosmWasmClient, owner: string, token: string, spender: string, amount: string) {
return client.execute(owner, token, { approve: { spender, amount } }, "auto");
}
async function transferFrom(client: SigningCosmWasmClient, spender: string, token: string, owner: string, to: string, amount: string) {
return client.execute(spender, token, { transfer_from: { owner, recipient: to, amount } }, "auto");
}
async function mint(client: SigningCosmWasmClient, minter: string, token: string, to: string, amount: string) {
return client.execute(minter, token, { mint: { recipient: to, amount } }, "auto");
}
async function burn(client: SigningCosmWasmClient, sender: string, token: string, amount: string) {
return client.execute(sender, token, { burn: { amount } }, "auto");
}
async function send(client: SigningCosmWasmClient, sender: string, token: string, contract: string, amount: string, msg: any) {
return client.execute(sender, token, { send: { contract, amount, msg: toBase64(msg) } }, "auto");
}
批量转账
async function batchTransfer(
client: SigningCosmWasmClient, sender: string, tokenAddress: string,
transfers: Array<{ recipient: string; amount: string }>
) {
for (const t of transfers) {
await client.execute(sender, tokenAddress, { transfer: t }, "auto");
}
}
5.3 Python 操作
async def transfer_tokens(client, sender: str, token_address: str, recipient: str, amount: str):
msg = {"transfer": {"recipient": recipient, "amount": amount}}
return await client.execute_contract(
sender=sender, contract_address=token_address, msg=json.dumps(msg).encode()
)
async def get_balance(client, token_address: str, address: str) -> int:
result = await client.query_contract_smart(
token_address, {"balance": {"address": address}}
)
return int(result["balance"])
async def approve_tokens(client, owner: str, token_address: str, spender: str, amount: str):
msg = {"approve": {"spender": spender, "amount": amount}}
return await client.execute_contract(
sender=owner, contract_address=token_address, msg=json.dumps(msg).encode()
)
async def mint_tokens(client, minter: str, token_address: str, recipient: str, amount: str):
msg = {"mint": {"recipient": recipient, "amount": amount}}
return await client.execute_contract(
sender=minter, contract_address=token_address, msg=json.dumps(msg).encode()
)
完整操作示例
import asyncio, json
from cosmipy import CosmWasmClient, Wallet
from cosmipy.models import Coin
async def token_operations():
wallet = Wallet.from_mnemonic("your mnemonic", prefix="msg")
client = await CosmWasmClient.connect(
rpc_url="http://localhost:26657", wallet=wallet,
gas_price=Coin(amount=25000000000000, denom="umsg"),
)
sender = wallet.address
TOKEN = "msg1token_address"
info = await client.query_contract_smart(TOKEN, {"token_info": {}})
print(f"Name: {info['name']}, Supply: {info['total_supply']}")
await client.execute_contract(sender, TOKEN, json.dumps({
"transfer": {"recipient": "msg1recipient", "amount": "50000000000000000000"}
}).encode())
print("Transfer done")
bal = await client.query_contract_smart(TOKEN, {"balance": {"address": sender}})
print(f"Balance: {bal['balance']}")
await client.disconnect()
# asyncio.run(token_operations())
6. CW20 扩展实现
6.1 Snapshot 代币(用于治理)
支持在指定区块高度记录余额快照,用于 DAO 治理投票。
// cw20-snapshot/src/state.rs
use cw_storage_plus::{Item, SnapshotMap};
use cosmwasm_std::{Addr, Uint128};
/// SnapshotMap 自动记录每个键的变更历史
pub const BALANCES_SNAPSHOT: SnapshotMap<&Addr, Uint128> = SnapshotMap::new(
"balance", // 主存储
"balance__chk", // 检查点
"balance__chg", // 变更历史
);
pub const SNAPSHOT_STRATEGY: Item<SnapshotStrategy> = Item::new("snapshot_strategy");
pub enum SnapshotStrategy {
EveryChange,
Periodic { block_interval: u64 },
}
/// 查询指定高度的余额快照
pub fn query_balance_at_height(
storage: &dyn cosmwasm_std::Storage,
address: &Addr,
height: u64,
) -> StdResult<Option<Uint128>> {
BALANCES_SNAPSHOT.may_load_at_height(storage, address, height)
}
/// 查询治理投票权
pub fn query_voting_power_at_height(
deps: Deps,
address: String,
height: u64,
) -> StdResult<Uint128> {
let addr = deps.api.addr_validate(&address)?;
Ok(BALANCES_SNAPSHOT
.may_load_at_height(deps.storage, &addr, height)?
.unwrap_or_default())
}
部署:将 cw20_base 的 BALANCES 替换为 BALANCES_SNAPSHOT,其余逻辑不变。
6.2 动态转账手续费代币
每笔转账收取一定比例的手续费。
// cw20-fee/src/state.rs
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct FeeConfig {
pub fee_bps: u16, // 手续费基点,如 30 = 0.3%
pub fee_collector: Addr, // 手续费接收地址
pub min_fee: Uint128, // 最小手续费
pub max_fee: Option<Uint128>, // 最大手续费
}
pub const FEE_CONFIG: Item<FeeConfig> = Item::new("fee_config");
// cw20-fee/src/contract.rs
fn apply_transfer_fee(
storage: &mut dyn cosmwasm_std::Storage,
sender: &Addr, amount: Uint128,
) -> StdResult<(Uint128, Uint128)> {
let fee_config = match FEE_CONFIG.may_load(storage)? {
Some(c) => c, None => return Ok((amount, Uint128::zero())),
};
let fee = amount.multiply_ratio(u128::from(fee_config.fee_bps))
.checked_div(Uint128::from(10000u128))
.unwrap_or_default();
let fee = if fee < fee_config.min_fee { fee_config.min_fee }
else if let Some(max) = fee_config.max_fee { if fee > max { max } else { fee } }
else { fee };
if fee.is_zero() { return Ok((amount, Uint128::zero())); }
let net = amount.checked_sub(fee)?;
BALANCES.update(storage, sender, |bal| Ok(bal.unwrap_or_default().checked_sub(fee)?))?;
BALANCES.update(storage, &fee_config.fee_collector, |bal| Ok(bal.unwrap_or_default() + fee))?;
Ok((net, fee))
}
6.3 可暂停代币(Emergency Stop)
// cw20-pausable/src/state.rs
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct PauseInfo {
pub paused: bool,
pub pauser: Addr,
pub paused_at: Option<u64>,
pub pause_reason: Option<String>,
}
pub const PAUSE_INFO: Item<PauseInfo> = Item::new("pause_info");
fn assert_not_paused(storage: &dyn cosmwasm_std::Storage) -> Result<(), ContractError> {
let info = PAUSE_INFO.may_load(storage)?.unwrap_or(PauseInfo {
paused: false, pauser: Addr::unchecked(""), paused_at: None, pause_reason: None,
});
if info.paused {
return Err(ContractError::Std(cosmwasm_std::StdError::generic_err(
format!("Paused: {}", info.pause_reason.unwrap_or_default())
)));
}
Ok(())
}
pub fn execute_pause(deps: DepsMut, env: Env, info: MessageInfo, reason: Option<String>) -> Result<Response, ContractError> {
let config = PAUSE_INFO.load(deps.storage)?;
if info.sender != config.pauser { return Err(ContractError::Unauthorized {}); }
PAUSE_INFO.save(deps.storage, &PauseInfo {
paused: true, pauser: config.pauser,
paused_at: Some(env.block.time.seconds()), pause_reason: reason,
})?;
Ok(Response::new().add_attribute("action", "pause"))
}
pub fn execute_unpause(deps: DepsMut, _env: Env, info: MessageInfo) -> Result<Response, ContractError> {
let config = PAUSE_INFO.load(deps.storage)?;
if info.sender != config.pauser { return Err(ContractError::Unauthorized {}); }
PAUSE_INFO.save(deps.storage, &PauseInfo {
paused: false, pauser: config.pauser, paused_at: None, pause_reason: None,
})?;
Ok(Response::new().add_attribute("action", "unpause"))
}
6.4 征税代币(每笔转账自动销毁/收集)
类似 RFI 风格的征税代币,每笔转账自动销毁一定比例并奖励持有者。
// cw20-tax/src/state.rs
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct TaxConfig {
pub burn_bps: u16, // 销毁比例(基点)
pub reward_pool_bps: u16, // 奖励池比例(基点)
pub reflection_bps: u16, // 反射比例(基点)
pub reward_pool: Option<Addr>,
}
impl TaxConfig {
pub fn total_tax_bps(&self) -> u16 {
self.burn_bps + self.reward_pool_bps + self.reflection_bps
}
}
pub const TAX_CONFIG: Item<TaxConfig> = Item::new("tax_config");
// cw20-tax/src/contract.rs
fn apply_tax(storage: &mut dyn cosmwasm_std::Storage, sender: &Addr, amount: Uint128) -> StdResult<Uint128> {
let config = TAX_CONFIG.load(storage)?;
let total_tax = config.total_tax_bps();
if total_tax == 0 { return Ok(amount); }
let tax = amount.multiply_ratio(u128::from(total_tax))
.checked_div(Uint128::from(10000u128)).unwrap_or_default();
if tax.is_zero() { return Ok(amount); }
let net = amount.checked_sub(tax)?;
let burn_amt = amount.multiply_ratio(u128::from(config.burn_bps))
.checked_div(Uint128::from(10000u128)).unwrap_or_default();
let reward_amt = amount.multiply_ratio(u128::from(config.reward_pool_bps))
.checked_div(Uint128::from(10000u128)).unwrap_or_default();
let reflection_amt = tax.checked_sub(burn_amt)?.checked_sub(reward_amt)?;
// 执行销毁
if !burn_amt.is_zero() {
TOKEN_INFO.update(storage, |mut info| -> StdResult<_> {
info.total_supply = info.total_supply.checked_sub(burn_amt)?;
Ok(info)
})?;
}
// 发送到奖励池
if !reward_amt.is_zero() {
if let Some(ref pool) = config.reward_pool {
BALANCES.update(storage, pool, |bal| Ok(bal.unwrap_or_default() + reward_amt))?;
}
}
// 反射:按比例增发到所有持有者(简化实现省略)
// ...
Ok(net)
}
7. 前端集成
7.1 React Hook — Token Balance
// hooks/useTokenBalance.ts
import { useState, useEffect, useCallback } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
interface Props {
client: SigningCosmWasmClient | null;
tokenAddress: string;
address: string;
refreshInterval?: number;
}
export function useTokenBalance({ client, tokenAddress, address, refreshInterval = 10000 }: Props) {
const [balance, setBalance] = useState("0");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tokenInfo, setTokenInfo] = useState<any>(null);
const fetchBalance = useCallback(async () => {
if (!client || !tokenAddress || !address) return;
setLoading(true);
try {
const [bal, info] = await Promise.all([
client.queryContractSmart(tokenAddress, { balance: { address } }),
tokenInfo ? Promise.resolve(tokenInfo) :
client.queryContractSmart(tokenAddress, { token_info: {} }),
]);
setBalance(bal.balance);
if (!tokenInfo) setTokenInfo(info);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, [client, tokenAddress, address, tokenInfo]);
useEffect(() => { fetchBalance(); }, [fetchBalance]);
useEffect(() => {
if (refreshInterval > 0) {
const id = setInterval(fetchBalance, refreshInterval);
return () => clearInterval(id);
}
}, [fetchBalance, refreshInterval]);
return { balance, tokenInfo, loading, error, refetch: fetchBalance };
}
7.2 React 组件 — Token Transfer
// components/TokenTransfer.tsx
import React, { useState } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
interface Props {
client: SigningCosmWasmClient;
tokenAddress: string;
sender: string;
decimals: number;
onSuccess?: (txHash: string) => void;
onError?: (err: Error) => void;
}
export function TokenTransfer({ client, tokenAddress, sender, decimals, onSuccess, onError }: Props) {
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
const [sending, setSending] = useState(false);
const handleTransfer = async (e: React.FormEvent) => {
e.preventDefault();
if (!recipient || !amount) return;
setSending(true);
try {
const rawAmount = BigInt(Math.floor(parseFloat(amount) * 10 ** decimals)).toString();
const result = await client.execute(sender, tokenAddress, {
transfer: { recipient, amount: rawAmount },
}, "auto");
onSuccess?.(result.transactionHash);
setAmount("");
setRecipient("");
} catch (err) {
onError?.(err as Error);
} finally {
setSending(false);
}
};
return (
<form onSubmit={handleTransfer} className="p-4 bg-white rounded shadow">
<h3 className="text-lg font-bold mb-3">Transfer Tokens</h3>
<div className="mb-3">
<label className="block text-sm font-medium mb-1">Recipient:</label>
<input type="text" value={recipient} onChange={(e) => setRecipient(e.target.value)}
placeholder="msg1..." disabled={sending} required
className="w-full p-2 border rounded" />
</div>
<div className="mb-3">
<label className="block text-sm font-medium mb-1">Amount:</label>
<input type="number" value={amount} onChange={(e) => setAmount(e.target.value)}
placeholder="0.0" min="0" step="any" disabled={sending} required
className="w-full p-2 border rounded" />
</div>
<button type="submit" disabled={sending || !recipient || !amount}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50">
{sending ? "Sending..." : "Transfer"}
</button>
</form>
);
}
7.3 React 组件 — Token Mint
// components/TokenMint.tsx
export function TokenMint({ client, tokenAddress, minter, decimals, onSuccess }: {
client: SigningCosmWasmClient; tokenAddress: string;
minter: string; decimals: number;
onSuccess?: (hash: string) => void;
}) {
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
const [minting, setMinting] = useState(false);
const handleMint = async (e: React.FormEvent) => {
e.preventDefault();
if (!recipient || !amount) return;
setMinting(true);
try {
const raw = BigInt(Math.floor(parseFloat(amount) * 10 ** decimals)).toString();
const res = await client.execute(minter, tokenAddress, {
mint: { recipient, amount: raw },
}, "auto");
onSuccess?.(res.transactionHash);
setAmount(""); setRecipient("");
} catch (err) {
console.error(err);
} finally {
setMinting(false);
}
};
return (
<form onSubmit={handleMint} className="p-4 bg-white rounded shadow">
<h3 className="text-lg font-bold mb-3">Mint Tokens</h3>
<div className="mb-3">
<label className="block text-sm font-medium mb-1">Recipient:</label>
<input type="text" value={recipient} onChange={(e) => setRecipient(e.target.value)}
placeholder="msg1..." disabled={minting} required className="w-full p-2 border rounded" />
</div>
<div className="mb-3">
<label className="block text-sm font-medium mb-1">Amount:</label>
<input type="number" value={amount} onChange={(e) => setAmount(e.target.value)}
placeholder="0.0" min="0" step="any" disabled={minting} required
className="w-full p-2 border rounded" />
</div>
<button type="submit" disabled={minting || !recipient || !amount}
className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 disabled:opacity-50">
{minting ? "Minting..." : "Mint"}
</button>
</form>
);
}
7.4 Keplr Wallet 集成
// hooks/useKeplr.ts
import { useState, useEffect } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
declare global { interface Window { keplr?: any; getOfflineSigner?: any; } }
export function useKeplr() {
const [client, setClient] = useState<SigningCosmWasmClient | null>(null);
const [address, setAddress] = useState("");
const [error, setError] = useState<string | null>(null);
const connect = async () => {
if (!window.keplr) { setError("Please install Keplr"); return; }
try {
await window.keplr.enable("msg-chain-1");
const offlineSigner = window.keplr.getOfflineSigner("msg-chain-1");
const accounts = await offlineSigner.getAccounts();
setAddress(accounts[0].address);
const client = await SigningCosmWasmClient.connectWithSigner(
"https://rpc.mainnet.msgchain.org", offlineSigner,
{ gasPrice: { denom: "umsg", amount: "1000000000" } },
);
setClient(client);
} catch (err) {
setError((err as Error).message);
}
};
return { client, address, error, connect };
}
8. 安全考虑
8.1 Approve/TransferFrom 重入攻击
风险:经典的 ERC20 重入攻击:Alice 授权 Bob 100 个代币,Bob 调用 transferFrom 消费 50 个,并在回调中再次尝试消费剩余部分。
防护(已在 CW20 标准中内置):
- CW20 的
Approve设置绝对值(无increaseApproval的竞态问题) TransferFrom在转账 前 扣除 allowance(Checks-Effects 模式)- 推荐使用
IncreaseAllowance/DecreaseAllowance代替裸Approve
// 正确:先扣 allowance 再转账
ALLOWANCES.update(storage, (&owner, &spender), |allow| {
let mut a = allow.unwrap_or(default);
a.allowance = a.allowance.checked_sub(amount)?; // Checks-Effects
Ok(a)
})?;
transfer_balance(storage, &owner, &recipient, amount)?; // Interactions
8.2 Approval Frontrunning
风险:Alice 将授权从 100 改为 50,Bob 看到交易并抢先提交消费原 100 + 新 50。
防护:使用 DecreaseAllowance(原子减少)而不是先设为 0 再设新值。
// ❌ 不安全:竞态窗口
execute_approve(spender, 0); // 重置
execute_approve(spender, 50); // 设置新值
// ✅ 安全:原子操作
execute_decrease_allowance(spender, 50);
8.3 初始余额溢出
风险:instantiate 中 initial_balances 的 amount 总和可能溢出 Uint128。
防护:Rust 的 checked_add 和 CosmWasm 的 overflow-checks = true 自动捕获。
let total_supply = msg.initial_balances.iter()
.try_fold(Uint128::zero(), |acc, c| acc.checked_add(c.amount))?;
// 溢出时会自动抛出错误
8.4 Minter 密钥管理
风险:minter 私钥泄露可能导致无限铸造。
防护:
- 设置
cap(铸造上限)限制最大供应量 - 使用多签地址作为 minter
- 用
UpdateMinter转移铸造权限到 DAO/时间锁合约
// 推荐:初始化时设置 cap
let minter = Some(MinterResponse {
minter: "msg1dao_multisig".to_string(),
cap: Some(Uint128::new(1_000_000_000_000_000_000_000_000)), // 1M tokens
});
8.5 Checks-Effects-Interactions 模式
// ✅ 正确顺序
fn execute_transfer_from(deps: DepsMut, ..., amount: Uint128) -> Result<Response, ContractError> {
// 1. Checks: 验证条件
check_allowance(deps.storage, &owner, &spender, amount, height, time)?;
// 2. Effects: 更新状态
ALLOWANCES.update(...)?;
// 3. Interactions: 发送消息
transfer_balance(deps.storage, &owner, &recipient, amount)?;
Ok(Response::new()...)
}
8.6 Expiration 验证
始终检查授权是否过期,防止在过期后继续使用。
if allowance.expires.is_expired(env.block.height, env.block.time.seconds()) {
return Err(ContractError::Expired {});
}
8.7 其他安全要点
| 风险 | 防护 |
|---|---|
| 重入攻击 | Checks-Effects-Interactions 模式 |
| 整数溢出 | overflow-checks = true, checked_* 方法 |
| 未授权铸造 | minter 权限检查 + cap 上限 |
| 未授权销毁 | 仅允许 owner 或 approved spender |
| 精度丢失 | 使用 Uint128,先乘后除 |
| 钓鱼转账 | Send 消息强制目标合约处理回调 |
| DOS 循环 | 设置查询分页 limit(默认 20,最大 100) |
8.8 工厂合约安全
// 1. 验证 reply 返回的合约地址
fn handle_create_token_reply(deps: DepsMut, msg: Reply) -> Result<Response, ContractError> {
let addr = msg.result.into_result()
.map_err(|e| ContractError::Std(StdError::generic_err(e)))?
.get_contract_address()
.ok_or(ContractError::InvalidReply {})?;
// ✅ 验证地址格式
let validated = deps.api.addr_validate(&addr)?;
// ...
}
// 2. 防止代币地址重复注册
if TOKEN_BY_ADDRESS.may_load(deps.storage, &validated)?.is_some() {
return Err(ContractError::TokenAlreadyRegistered {});
}
// 3. 费用检查使用 checked 操作
let provided = info.funds.iter()
.find(|c| c.denom == fee.denom)
.map(|c| c.amount)
.unwrap_or(Uint128::zero());
if provided < fee.amount {
return Err(ContractError::InsufficientFee { ... });
}
9. 完整示例项目
9.1 端到端流程:部署 Factory → 创建代币 → 转账
# ===== 阶段 1: 编译和存储合约 =====
# 编译 cw20_base
cd contracts/cosmwasm/all/cw20-base
RUSTFLAGS='-C link-arg=-s' cargo build --release --target wasm32-unknown-unknown
wasm-opt -Os target/wasm32-unknown-unknown/release/cw20_base.wasm -o ../../../../artifacts/cw20_base.wasm
# 编译 token-factory
cd ../../token-factory
RUSTFLAGS='-C link-arg=-s' cargo build --release --target wasm32-unknown-unknown
wasm-opt -Os target/wasm32-unknown-unknown/release/token_factory.wasm -o ../../../../artifacts/token_factory.wasm
cd ../../../..
# ===== 阶段 2: 存储代码 =====
# 存储 cw20_base
CW20_CODE_ID=$(msgd tx wasm store artifacts/cw20_base.wasm \
--from validator --gas auto --gas-prices 1000000000attoMSG \
--chain-id msg-chain-1 -y --output json \
| jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "cw20_base code_id: $CW20_CODE_ID"
# 存储 factory
FACTORY_CODE_ID=$(msgd tx wasm store artifacts/token_factory.wasm \
--from validator --gas auto --gas-prices 1000000000attoMSG \
--chain-id msg-chain-1 -y --output json \
| jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "factory code_id: $FACTORY_CODE_ID"
# ===== 阶段 3: 部署 Factory =====
FACTORY_ADDR=$(msgd tx wasm instantiate $FACTORY_CODE_ID \
'{"token_code_id":'$CW20_CODE_ID',"creation_fee":{"denom":"umsg","amount":"1000000000000000000"}}' \
--label "TokenFactory" --admin $(msgd keys show validator -a) \
--from validator --gas auto --gas-prices 1000000000attoMSG \
--chain-id msg-chain-1 -y --output json \
| jq -r '.logs[0].events[] | select(.type=="instantiate") | .attributes[] | select(.key=="_contract_address") | .value')
echo "Factory: $FACTORY_ADDR"
# ===== 阶段 4: 通过 Factory 创建代币 =====
CREATOR=$(msgd keys show validator -a)
# 创建代币(支付 1 MSG 创建费)
TOKEN_RESULT=$(msgd tx wasm execute $FACTORY_ADDR \
'{"create_token":{"name":"MyGold","symbol":"GLD","decimals":18,"initial_balances":[{"address":"'$CREATOR'","amount":"1000000000000000000000000"}],"minter":"'$CREATOR'","cap":"10000000000000000000000000000"}}' \
--from validator --gas auto --gas-prices 1000000000attoMSG \
--chain-id msg-chain-1 -y --amount 1000000000000000000umsg --output json)
TOKEN_ADDR=$(echo $TOKEN_RESULT | jq -r '.logs[0].events[] | select(.type=="reply") | .attributes[] | select(.key=="token_address") | .value')
echo "Token: $TOKEN_ADDR"
# ===== 阶段 5: 查询 =====
msgd query wasm contract-state smart $TOKEN_ADDR '{"token_info":{}}'
msgd query wasm contract-state smart $TOKEN_ADDR '{"balance":{"address":"'$CREATOR'"}}'
msgd query wasm contract-state smart $FACTORY_ADDR '{"token_count":{}}'
# ===== 阶段 6: 转账 =====
RECIPIENT="msg1recipient_address"
msgd tx wasm execute $TOKEN_ADDR \
'{"transfer":{"recipient":"'$RECIPIENT'","amount":"100000000000000000000"}}' \
--from validator --gas auto --gas-prices 1000000000attoMSG --chain-id msg-chain-1 -y
# 验证转账
msgd query wasm contract-state smart $TOKEN_ADDR '{"balance":{"address":"'$RECIPIENT'"}}'
# ===== 阶段 7: 查询工厂所有代币 =====
msgd query wasm contract-state smart $FACTORY_ADDR '{"list_tokens":{"limit":10}}'
9.2 TypeScript 完整示例
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";
import { GasPrice } from "@cosmjs/stargate";
import fs from "fs";
async function main() {
// 1. 设置客户端
const wallet = await DirectSecp256k1HdWallet.fromMnemonic(
"test mnemonic", { prefix: "msg" }
);
const client = await SigningCosmWasmClient.connectWithSigner(
"http://localhost:26657", wallet,
{ gasPrice: GasPrice.fromString("1000000000attoMSG") }
);
const [account] = await wallet.getAccounts();
const deployer = account.address;
// 2. 存储合约
const cw20Wasm = fs.readFileSync("artifacts/cw20_base.wasm");
const cw20CodeId = (await client.upload(deployer, cw20Wasm, "auto")).codeId;
const factoryWasm = fs.readFileSync("artifacts/token_factory.wasm");
const factoryCodeId = (await client.upload(deployer, factoryWasm, "auto")).codeId;
console.log(`cw20_base code: ${cw20CodeId}, factory code: ${factoryCodeId}`);
// 3. 部署 Factory
const factoryAddr = (await client.instantiate(deployer, factoryCodeId, {
token_code_id: cw20CodeId,
creation_fee: { denom: "umsg", amount: "1000000000000000000" },
fee_collector: null,
}, "Factory", { admin: deployer, label: "v1" })).contractAddress;
console.log(`Factory: ${factoryAddr}`);
// 4. 创建代币
const result = await client.execute(deployer, factoryAddr, {
create_token: {
name: "Gold", symbol: "GLD", decimals: 18,
initial_balances: [{ address: deployer, amount: "1000000000000000000000000" }],
minter: deployer, cap: "10000000000000000000000000000",
},
}, "auto", undefined, [{ denom: "umsg", amount: "1000000000000000000" }]);
const tokenAddr = result.events
.find(e => e.type === "wasm")
?.attributes.find(a => a.key === "token_address")?.value || "";
console.log(`Token: ${tokenAddr}`);
// 5. 查询
const info: any = await client.queryContractSmart(tokenAddr, { token_info: {} });
console.log(`Name: ${info.name}, Supply: ${info.total_supply}`);
const balance: any = await client.queryContractSmart(tokenAddr, { balance: { address: deployer } });
console.log(`Balance: ${balance.balance}`);
// 6. 转账
await client.execute(deployer, tokenAddr, {
transfer: { recipient: "msg1recipient", amount: "100000000000000000000" },
}, "auto");
const recipientBal: any = await client.queryContractSmart(
tokenAddr, { balance: { address: "msg1recipient" } }
);
console.log(`Recipient balance: ${recipientBal.balance}`);
}
main().catch(console.error);
10. 附录
A. 完整消息 Schema
InstantiateMsg:
{
"name": "string",
"symbol": "string",
"decimals": "uint8 (0-18)",
"initial_balances": [
{"address": "string (msg1...)","amount": "string (Uint128)"}
],
"mint": null | {
"minter": "string",
"cap": null | "string (Uint128)"
},
"marketing": null | {
"project": null | "string",
"description": null | "string",
"marketing": null | "string",
"logo": null | {"url": "string"} | {"embedded": "Binary (base64)"}
}
}
ExecuteMsg(枚举,以下之一):
{"transfer": {"recipient": "string", "amount": "string"}}
{"approve": {"spender": "string", "amount": "string", "expires": null|{"at_height":64}|{"at_time":64}|{"never":{}}}
{"transfer_from": {"owner": "string", "recipient": "string", "amount": "string"}}
{"burn": {"amount": "string"}}
{"mint": {"recipient": "string", "amount": "string"}}
{"send": {"contract": "string", "amount": "string", "msg": "Binary"}}
{"increase_allowance": {"spender": "string", "amount": "string", "expires": ...}}
{"decrease_allowance": {"spender": "string", "amount": "string", "expires": ...}}
{"update_minter": {"new_minter": null|"string"}}
{"set_marketing": {"project": null|"string", "description": null|"string", "marketing": null|"string"}}
QueryMsg(枚举,以下之一):
{"balance": {"address": "string"}}
{"token_info": {}}
{"minter": {}}
{"allowance": {"owner": "string", "spender": "string"}}
{"all_accounts": {"start_after": null|"string", "limit": null|32}}
{"all_allowances": {"owner": "string", "start_after": null|"string", "limit": null|32}}
{"marketing_info": {}}
B. Event 属性
每次执行操作时合约发出以下事件:
| 操作 | Event Type | Attributes |
|---|---|---|
| Transfer | wasm |
action=transfer, from, to, amount |
| Approve | wasm |
action=approve, owner, spender, amount |
| TransferFrom | wasm |
action=transfer_from, from, to, by, amount |
| Burn | wasm |
action=burn, from, amount |
| Mint | wasm |
action=mint, to, amount |
| Send | wasm |
action=send, from, to, amount |
C. 精度换算速查表
| 用户显示数量 | 原始金额(18位精度) |
|---|---|
| 0.000001 | 1000000000000 |
| 0.001 | 1000000000000000 |
| 0.01 | 10000000000000000 |
| 0.1 | 100000000000000000 |
| 1 | 1000000000000000000 |
| 10 | 10000000000000000000 |
| 100 | 100000000000000000000 |
| 1000 | 1000000000000000000000 |
D. Gas 估算参考
| 操作 | 估算 Gas | 费用(Gas x 1,000,000,000 attoMSG) |
|---|---|---|
| Transfer | ~150,000 | 3,750 umsg |
| Approve | ~120,000 | 3,000 umsg |
| TransferFrom | ~180,000 | 4,500 umsg |
| Mint | ~160,000 | 4,000 umsg |
| Burn | ~130,000 | 3,250 umsg |
| Factory CreateToken | ~400,000 | 10,000 umsg |
| Factory Query List | ~100,000 | 2,500 umsg |
E. 常见错误排查
| 错误信息 | 原因 | 解决 |
|---|---|---|
Overflow: insufficient balance |
余额不足 | 检查发送者余额 |
Expired |
授权已过期 | 重新授权或设置 Never |
Unauthorized |
调用者无权限 | 检查 minter/owner 地址 |
CapExceeded |
铸造超过上限 | 提高 cap 或等待销毁后再铸 |
InsufficientFee |
创建代币未付费用 | 附带 --amount 参数 |
NotMintable |
代币不可铸造 | 初始化时设置 mint 字段 |
decimals must not exceed 18 |
精度 > 18 | 设置 decimals <= 18 |
本文档基于 MSG Chain 代码库核实的技术事实。
白皮书系统: https://msgchain.org/whitepaper/
本文档为 MSG Chain 开发者提供 CW20 代币标准、完整实现、工厂合约及操作指南。
相关资源:
- CW20 规范: https://github.com/CosmWasm/cw-plus/blob/main/packages/cw20/README.md
- CosmWasm 文档: https://docs.cosmwasm.com
- MSG Chain DevKit:
msg-devkit --help
