AI Agent Marketplace — 完整去中心化应用指南
基于 MSG Chain (msg-chain-1) 的全栈 dApp 实现,包含 CosmWasm 智能合约、Node.js API 服务、React 前端及部署脚本
对标:类似 OpenSea 的 Agent 市场 + 类似 Fiverr 的服务交易⚠️ No-Go Disclaimer: MSGChain 主网裁决为 No-Go。本文件所有内容反映的是开发阶段的技术设计,不代表主网未独立核验上线状态。生产部署状态请以白皮书为准:https://msgchain.org/whitepaper/
目录
1. 项目概览
1.1 链配置
| 参数 | 值 |
|---|---|
| Chain ID | msg-chain-1 |
| Bech32 前缀 | msg |
| Coin 类型 | 118 |
| MSG 精度 | 18 位小数 |
| Gas 价格 | 1,000,000,000 attoMSG/gas |
| 出块时间 | 5 秒 |
| 共识机制 | Round-Robin + DAR |
1.2 合约架构
| 合约 | 作用 | 依赖 |
|---|---|---|
agent_registry_v1 |
Agent 注册/注销/状态 | DID Registry |
aidid_did_registry_v1 |
去中心化身份 | - |
agent_payment_v1 |
支付会话/里程碑/争议 | Registry |
ai_agent_constitution_v1 |
规则/权限/治理 | - |
marketplace_v1 (新建) |
市场列表/搜索/评价 | Registry + Payment |
2. Part 1: Marketplace 智能合约
2.1 项目结构
contracts/marketplace_v1/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── contract.rs
│ ├── msg.rs
│ ├── state.rs
│ └── error.rs
└── examples/
└── schema.rs
2.2 Cargo.toml
[package]
name = "marketplace-v1"
version = "1.0.0"
edition = "2021"
description = "AI Agent Marketplace for MSG Chain"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
cosmwasm-std = { version = "1.5", features = ["staking"] }
cosmwasm-storage = "1.5"
cw-storage-plus = "1.2"
cw2 = "1.1"
schemars = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
uint = "0.9"
[dev-dependencies]
cosmwasm-schema = "1.5"
[profile.release]
opt-level = 3
debug = false
lto = true
panic = "abort"
overflow-checks = true
2.3 msg.rs -- 消息定义
use cosmwasm_std::{Addr, Uint128, Binary};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg {
pub registry_contract: String,
pub payment_contract: String,
pub constitution_contract: String,
pub did_registry_contract: String,
pub admin: String,
pub listing_fee: Option<CoinMsg>,
pub platform_fee_percent: Option<u64>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct CoinMsg {
pub denom: String,
pub amount: Uint128,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecuteMsg {
CreateListing {
agent_id: String,
title: String,
description: String,
price: CoinMsg,
pricing_model: PricingModel,
categories: Vec<String>,
tags: Vec<String>,
delivery_days: Option<u32>,
terms: Option<String>,
},
UpdateListing {
listing_id: String,
title: Option<String>,
description: Option<String>,
price: Option<CoinMsg>,
pricing_model: Option<PricingModel>,
categories: Option<Vec<String>>,
tags: Option<Vec<String>>,
delivery_days: Option<u32>,
terms: Option<String>,
active: Option<bool>,
},
RemoveListing { listing_id: String },
SubmitReview {
listing_id: String,
session_id: String,
rating: u8,
review_text: Option<String>,
},
ReplyToReview { review_id: String, reply_text: String },
CreateOrder {
listing_id: String,
client: String,
milestones: Vec<OrderMilestone>,
expiry: u64,
},
AcceptOrder { order_id: String },
RejectOrder { order_id: String, reason: Option<String> },
CancelOrder { order_id: String, reason: Option<String> },
CompleteOrder { order_id: String },
MarkDelivered { order_id: String, delivery_proof: Option<Binary> },
AdminRemoveListing { listing_id: String, reason: String },
AdminUpdateFee { new_fee_percent: u64 },
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum QueryMsg {
GetListing { listing_id: String },
ListListings {
start_after: Option<String>,
limit: Option<u32>,
filters: Option<ListingFilters>,
},
GetListingsByAgent {
agent_id: String,
status: Option<ListingStatus>,
start_after: Option<String>,
limit: Option<u32>,
},
GetListingsByOwner {
owner: String,
start_after: Option<String>,
limit: Option<u32>,
},
GetListingsByCategory {
category: String,
start_after: Option<String>,
limit: Option<u32>,
},
SearchListings {
query: String,
categories: Option<Vec<String>>,
min_price: Option<Uint128>,
max_price: Option<Uint128>,
pricing_model: Option<PricingModel>,
min_rating: Option<u8>,
start_after: Option<String>,
limit: Option<u32>,
},
GetReviews {
listing_id: String,
start_after: Option<String>,
limit: Option<u32>,
},
GetOrder { order_id: String },
GetOrdersByClient {
client: String,
status: Option<OrderStatus>,
start_after: Option<String>,
limit: Option<u32>,
},
GetOrdersByAgent {
agent_id: String,
status: Option<OrderStatus>,
start_after: Option<String>,
limit: Option<u32>,
},
GetMarketplaceStats {},
GetConfig {},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PricingModel { Fixed, Hourly, Milestone }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ListingStatus { Active, Paused, Removed, AdminRemoved }
impl ToString for ListingStatus {
fn to_string(&self) -> String {
match self {
ListingStatus::Active => "active".to_string(),
ListingStatus::Paused => "paused".to_string(),
ListingStatus::Removed => "removed".to_string(),
ListingStatus::AdminRemoved => "admin_removed".to_string(),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Listing {
pub listing_id: String,
pub agent_id: String,
pub owner: Addr,
pub title: String,
pub description: String,
pub price: CoinMsg,
pub pricing_model: PricingModel,
pub categories: Vec<String>,
pub tags: Vec<String>,
pub delivery_days: Option<u32>,
pub terms: Option<String>,
pub status: ListingStatus,
pub total_reviews: u32,
pub average_rating: u8,
pub total_orders: u32,
pub completed_orders: u32,
pub created_at: u64,
pub updated_at: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ListingFilters {
pub status: Option<ListingStatus>,
pub pricing_model: Option<PricingModel>,
pub categories: Option<Vec<String>>,
pub min_rating: Option<u8>,
pub min_price: Option<Uint128>,
pub max_price: Option<Uint128>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Review {
pub review_id: String,
pub listing_id: String,
pub session_id: String,
pub reviewer: Addr,
pub rating: u8,
pub review_text: Option<String>,
pub reply: Option<String>,
pub created_at: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct OrderMilestone {
pub title: String,
pub description: String,
pub amount: Uint128,
pub due_days: u32,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OrderStatus {
Pending, Active, Delivered, Completed, Cancelled, Disputed,
}
impl ToString for OrderStatus {
fn to_string(&self) -> String {
match self {
OrderStatus::Pending => "pending".to_string(),
OrderStatus::Active => "active".to_string(),
OrderStatus::Delivered => "delivered".to_string(),
OrderStatus::Completed => "completed".to_string(),
OrderStatus::Cancelled => "cancelled".to_string(),
OrderStatus::Disputed => "disputed".to_string(),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Order {
pub order_id: String,
pub listing_id: String,
pub agent_id: String,
pub client: Addr,
pub agent_owner: Addr,
pub milestones: Vec<OrderMilestone>,
pub total_amount: Uint128,
pub denom: String,
pub status: OrderStatus,
pub session_id: Option<String>,
pub delivery_proof: Option<Binary>,
pub created_at: u64,
pub updated_at: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct MarketplaceStats {
pub total_listings: u32,
pub active_listings: u32,
pub total_orders: u32,
pub completed_orders: u32,
pub total_reviews: u32,
pub total_agents: u32,
pub total_volume: CoinMsg,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Config {
pub registry_contract: Addr,
pub payment_contract: Addr,
pub constitution_contract: Addr,
pub did_registry_contract: Addr,
pub admin: Addr,
pub listing_fee: Option<CoinMsg>,
pub platform_fee_percent: u64,
pub listing_counter: u64,
pub review_counter: u64,
pub order_counter: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ListingsResponse { pub listings: Vec<Listing> }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ReviewsResponse { pub reviews: Vec<Review> }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct OrdersResponse { pub orders: Vec<Order> }
2.4 state.rs -- 链上状态
use cosmwasm_std::Addr;
use cw_storage_plus::{Item, Map};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::msg::{Config, Listing, ListingStatus, MarketplaceStats, Order, OrderStatus, Review, CoinMsg};
pub const CONFIG: Item<Config> = Item::new("config");
pub const STATS: Item<MarketplaceStats> = Item::new("stats");
pub const LISTINGS: Map<&str, Listing> = Map::new("listings");
pub const LISTINGS_BY_OWNER: Map<(&Addr, &str), bool> = Map::new("l_by_owner");
pub const LISTINGS_BY_AGENT: Map<(&str, &str), bool> = Map::new("l_by_agent");
pub const LISTINGS_BY_CATEGORY: Map<(&str, &str), bool> = Map::new("l_by_cat");
pub const LISTINGS_BY_STATUS: Map<(&str, &str), bool> = Map::new("l_by_status");
pub const REVIEWS: Map<&str, Review> = Map::new("reviews");
pub const REVIEWS_BY_LISTING: Map<(&str, &str), bool> = Map::new("r_by_listing");
pub const ORDERS: Map<&str, Order> = Map::new("orders");
pub const ORDERS_BY_CLIENT: Map<(&Addr, &str), bool> = Map::new("o_by_client");
pub const ORDERS_BY_AGENT: Map<(&str, &str), bool> = Map::new("o_by_agent");
pub const ORDERS_BY_STATUS: Map<(&str, &str), bool> = Map::new("o_by_status");
pub const AGENT_RATING: Map<&str, AgentRating> = Map::new("agent_rating");
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct AgentRating {
pub agent_id: String,
pub total_reviews: u32,
pub average_rating: u8,
pub total_orders: u32,
pub completed_orders: u32,
}
pub const DEFAULT_LIMIT: u32 = 20;
pub const MAX_LIMIT: u32 = 50;
pub fn calc_next_id(prefix: &str, counter: u64) -> String {
format!("{}_{}", prefix, counter)
}
2.5 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("Listing {listing_id} not found")]
ListingNotFound { listing_id: String },
#[error("Order {order_id} not found")]
OrderNotFound { order_id: String },
#[error("Review {review_id} not found")]
ReviewNotFound { review_id: String },
#[error("Agent {agent_id} not available")]
AgentNotAvailable { agent_id: String },
#[error("Listing {listing_id} is not active")]
ListingNotActive { listing_id: String },
#[error("Order {order_id} is not active")]
OrderNotActive { order_id: String },
#[error("Invalid rating {rating}, must be 1-5")]
InvalidRating { rating: u8 },
#[error("Duplicate listing for agent {agent_id}")]
DuplicateListing { agent_id: String },
#[error("Invalid price amount")]
InvalidPrice,
#[error("Fee too high: {fee}, max 1000 bps")]
FeeTooHigh { fee: u64 },
#[error("Insufficient funds")]
InsufficientFunds,
}
2.6 contract.rs -- 合约入口
use cosmwasm_std::{
entry_point, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo,
Response, StdResult, Uint128, Order as CwOrder,
};
use cw2::set_contract_version;
use crate::error::ContractError;
use crate::msg::{
Config, ExecuteMsg, InstantiateMsg, Listing, ListingFilters, ListingStatus,
MarketplaceStats, Order, OrderMilestone, OrderStatus, PricingModel, QueryMsg,
Review, ListingsResponse, ReviewsResponse, OrdersResponse, CoinMsg,
};
use crate::state::{
AgentRating, AGENT_RATING, CONFIG, STATS, LISTINGS, LISTINGS_BY_OWNER,
LISTINGS_BY_AGENT, LISTINGS_BY_CATEGORY, LISTINGS_BY_STATUS, REVIEWS,
REVIEWS_BY_LISTING, ORDERS, ORDERS_BY_CLIENT, ORDERS_BY_AGENT, ORDERS_BY_STATUS,
DEFAULT_LIMIT, MAX_LIMIT, calc_next_id,
};
const CONTRACT_NAME: &str = "marketplace-v1";
const CONTRACT_VERSION: &str = "1.0.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)?;
let config = Config {
registry_contract: deps.api.addr_validate(&msg.registry_contract)?,
payment_contract: deps.api.addr_validate(&msg.payment_contract)?,
constitution_contract: deps.api.addr_validate(&msg.constitution_contract)?,
did_registry_contract: deps.api.addr_validate(&msg.did_registry_contract)?,
admin: deps.api.addr_validate(&msg.admin)?,
listing_fee: msg.listing_fee,
platform_fee_percent: msg.platform_fee_percent.unwrap_or(250),
listing_counter: 0,
review_counter: 0,
order_counter: 0,
};
if config.platform_fee_percent > 1000 {
return Err(ContractError::FeeTooHigh { fee: config.platform_fee_percent }.into());
}
CONFIG.save(deps.storage, &config)?;
let stats = MarketplaceStats {
total_listings: 0, active_listings: 0, total_orders: 0, completed_orders: 0,
total_reviews: 0, total_agents: 0,
total_volume: CoinMsg { denom: "umsg".to_string(), amount: Uint128::zero() },
};
STATS.save(deps.storage, &stats)?;
Ok(Response::new()
.add_attribute("action", "instantiate")
.add_attribute("contract", CONTRACT_NAME))
}
#[entry_point]
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response, ContractError> {
match msg {
ExecuteMsg::CreateListing { agent_id, title, description, price, pricing_model, categories, tags, delivery_days, terms } =>
execute_create_listing(deps, env, info, agent_id, title, description, price, pricing_model, categories, tags, delivery_days, terms),
ExecuteMsg::UpdateListing { listing_id, title, description, price, pricing_model, categories, tags, delivery_days, terms, active } =>
execute_update_listing(deps, env, info, listing_id, title, description, price, pricing_model, categories, tags, delivery_days, terms, active),
ExecuteMsg::RemoveListing { listing_id } => execute_remove_listing(deps, env, info, listing_id),
ExecuteMsg::SubmitReview { listing_id, session_id, rating, review_text } =>
execute_submit_review(deps, env, info, listing_id, session_id, rating, review_text),
ExecuteMsg::ReplyToReview { review_id, reply_text } => execute_reply_to_review(deps, env, info, review_id, reply_text),
ExecuteMsg::CreateOrder { listing_id, client, milestones, expiry } =>
execute_create_order(deps, env, info, listing_id, client, milestones, expiry),
ExecuteMsg::AcceptOrder { order_id } => execute_accept_order(deps, env, info, order_id),
ExecuteMsg::RejectOrder { order_id, reason } => execute_reject_order(deps, env, info, order_id, reason),
ExecuteMsg::CancelOrder { order_id, reason } => execute_cancel_order(deps, env, info, order_id, reason),
ExecuteMsg::CompleteOrder { order_id } => execute_complete_order(deps, env, info, order_id),
ExecuteMsg::MarkDelivered { order_id, delivery_proof } => execute_mark_delivered(deps, env, info, order_id, delivery_proof),
ExecuteMsg::AdminRemoveListing { listing_id, reason: _ } => execute_admin_remove_listing(deps, env, info, listing_id),
ExecuteMsg::AdminUpdateFee { new_fee_percent } => execute_admin_update_fee(deps, env, info, new_fee_percent),
}
}
2.6 contract.rs -- Execute 实现
fn execute_create_listing(
deps: DepsMut, env: Env, info: MessageInfo,
agent_id: String, title: String, description: String, price: CoinMsg,
pricing_model: PricingModel, categories: Vec<String>, tags: Vec<String>,
delivery_days: Option<u32>, terms: Option<String>,
) -> Result<Response, ContractError> {
if price.amount.is_zero() { return Err(ContractError::InvalidPrice); }
let config = CONFIG.load(deps.storage)?;
let counter = config.listing_counter + 1;
CONFIG.update(deps.storage, |mut c| { c.listing_counter = counter; Ok(c) })?;
let listing_id = calc_next_id("ls", counter);
let listing = Listing {
listing_id: listing_id.clone(), agent_id: agent_id.clone(), owner: info.sender.clone(),
title, description, price, pricing_model, categories: categories.clone(), tags,
delivery_days, terms, status: ListingStatus::Active,
total_reviews: 0, average_rating: 0, total_orders: 0, completed_orders: 0,
created_at: env.block.time.seconds(), updated_at: env.block.time.seconds(),
};
LISTINGS.save(deps.storage, &listing_id, &listing)?;
LISTINGS_BY_OWNER.save(deps.storage, (&info.sender, &listing_id), &true)?;
LISTINGS_BY_AGENT.save(deps.storage, (&agent_id, &listing_id), &true)?;
LISTINGS_BY_STATUS.save(deps.storage, (&ListingStatus::Active.to_string(), &listing_id), &true)?;
for cat in &categories { LISTINGS_BY_CATEGORY.save(deps.storage, (cat, &listing_id), &true)?; }
STATS.update(deps.storage, |mut s| { s.total_listings += 1; s.active_listings += 1; Ok(s) })?;
Ok(Response::new().add_attribute("action", "create_listing").add_attribute("listing_id", &listing_id))
}
fn execute_update_listing(
deps: DepsMut, env: Env, info: MessageInfo,
listing_id: String, title: Option<String>, description: Option<String>,
price: Option<CoinMsg>, pricing_model: Option<PricingModel>,
categories: Option<Vec<String>>, tags: Option<Vec<String>>,
delivery_days: Option<u32>, terms: Option<String>, active: Option<bool>,
) -> Result<Response, ContractError> {
let mut listing = LISTINGS.load(deps.storage, &listing_id)
.map_err(|_| ContractError::ListingNotFound { listing_id: listing_id.clone() })?;
if info.sender != listing.owner { return Err(ContractError::Unauthorized); }
if let Some(t) = title { listing.title = t; }
if let Some(d) = description { listing.description = d; }
if let Some(p) = price { if p.amount.is_zero() { return Err(ContractError::InvalidPrice); } listing.price = p; }
if let Some(p) = pricing_model { listing.pricing_model = p; }
if let Some(c) = categories {
for cat in &listing.categories { LISTINGS_BY_CATEGORY.remove(deps.storage, (cat, &listing_id)); }
for cat in &c { LISTINGS_BY_CATEGORY.save(deps.storage, (cat, &listing_id), &true)?; }
listing.categories = c;
}
if let Some(t) = tags { listing.tags = t; }
if let Some(d) = delivery_days { listing.delivery_days = Some(d); }
if let Some(t) = terms { listing.terms = Some(t); }
if let Some(a) = active {
let old = listing.status.clone();
listing.status = if a { ListingStatus::Active } else { ListingStatus::Paused };
LISTINGS_BY_STATUS.remove(deps.storage, (&old.to_string(), &listing_id));
LISTINGS_BY_STATUS.save(deps.storage, (&listing.status.to_string(), &listing_id), &true)?;
}
listing.updated_at = env.block.time.seconds();
LISTINGS.save(deps.storage, &listing_id, &listing)?;
Ok(Response::new().add_attribute("action", "update_listing").add_attribute("listing_id", &listing_id))
}
fn execute_remove_listing(
deps: DepsMut, _env: Env, info: MessageInfo, listing_id: String,
) -> Result<Response, ContractError> {
let listing = LISTINGS.load(deps.storage, &listing_id)
.map_err(|_| ContractError::ListingNotFound { listing_id: listing_id.clone() })?;
if info.sender != listing.owner { return Err(ContractError::Unauthorized); }
LISTINGS_BY_OWNER.remove(deps.storage, (&listing.owner, &listing_id));
LISTINGS_BY_AGENT.remove(deps.storage, (&listing.agent_id, &listing_id));
LISTINGS_BY_STATUS.remove(deps.storage, (&listing.status.to_string(), &listing_id));
for cat in &listing.categories { LISTINGS_BY_CATEGORY.remove(deps.storage, (cat, &listing_id)); }
LISTINGS.remove(deps.storage, &listing_id);
STATS.update(deps.storage, |mut s| {
s.total_listings = s.total_listings.saturating_sub(1);
if matches!(listing.status, ListingStatus::Active) { s.active_listings = s.active_listings.saturating_sub(1); }
Ok(s)
})?;
Ok(Response::new().add_attribute("action", "remove_listing").add_attribute("listing_id", &listing_id))
}
fn execute_submit_review(
deps: DepsMut, env: Env, info: MessageInfo,
listing_id: String, session_id: String, rating: u8, review_text: Option<String>,
) -> Result<Response, ContractError> {
if rating < 1 || rating > 5 { return Err(ContractError::InvalidRating { rating }); }
let listing = LISTINGS.load(deps.storage, &listing_id)
.map_err(|_| ContractError::ListingNotFound { listing_id: listing_id.clone() })?;
let config = CONFIG.load(deps.storage)?;
let counter = config.review_counter + 1;
CONFIG.update(deps.storage, |mut c| { c.review_counter = counter; Ok(c) })?;
let review_id = calc_next_id("rev", counter);
let review = Review {
review_id: review_id.clone(), listing_id: listing_id.clone(), session_id,
reviewer: info.sender.clone(), rating, review_text, reply: None,
created_at: env.block.time.seconds(),
};
REVIEWS.save(deps.storage, &review_id, &review)?;
REVIEWS_BY_LISTING.save(deps.storage, (&listing_id, &review_id), &true)?;
let total = listing.total_reviews + 1;
let avg = ((listing.average_rating as u32 * listing.total_reviews + rating as u32) / total) as u8;
LISTINGS.update(deps.storage, &listing_id, |l| { let mut l = l.unwrap(); l.total_reviews = total; l.average_rating = avg; Ok(l) })?;
AGENT_RATING.update(deps.storage, &listing.agent_id, |r| {
let mut r = r.unwrap_or(AgentRating { agent_id: listing.agent_id.clone(), total_reviews: 0, average_rating: 0, total_orders: 0, completed_orders: 0 });
let old = r.total_reviews; r.total_reviews = old + 1;
r.average_rating = ((r.average_rating as u32 * old + rating as u32) / r.total_reviews) as u8;
Ok(r)
})?;
STATS.update(deps.storage, |mut s| { s.total_reviews += 1; Ok(s) })?;
Ok(Response::new().add_attribute("action", "submit_review").add_attribute("review_id", &review_id))
}
fn execute_reply_to_review(
deps: DepsMut, _env: Env, info: MessageInfo, review_id: String, reply_text: String,
) -> Result<Response, ContractError> {
let review = REVIEWS.load(deps.storage, &review_id)
.map_err(|_| ContractError::ReviewNotFound { review_id: review_id.clone() })?;
let listing = LISTINGS.load(deps.storage, &review.listing_id)?;
if info.sender != listing.owner { return Err(ContractError::Unauthorized); }
REVIEWS.update(deps.storage, &review_id, |r| { let mut r = r.unwrap(); r.reply = Some(reply_text); Ok(r) })?;
Ok(Response::new().add_attribute("action", "reply_to_review").add_attribute("review_id", &review_id))
}
fn execute_create_order(
deps: DepsMut, env: Env, info: MessageInfo,
listing_id: String, client: String, milestones: Vec<OrderMilestone>, expiry: u64,
) -> Result<Response, ContractError> {
let listing = LISTINGS.load(deps.storage, &listing_id)
.map_err(|_| ContractError::ListingNotFound { listing_id: listing_id.clone() })?;
if !matches!(listing.status, ListingStatus::Active) { return Err(ContractError::ListingNotActive { listing_id }); }
let client_addr = deps.api.addr_validate(&client)?;
if info.sender != client_addr && info.sender != listing.owner { return Err(ContractError::Unauthorized); }
let config = CONFIG.load(deps.storage)?;
let counter = config.order_counter + 1;
CONFIG.update(deps.storage, |mut c| { c.order_counter = counter; Ok(c) })?;
let order_id = calc_next_id("ord", counter);
let total_amount: Uint128 = milestones.iter().map(|m| m.amount).sum();
let order = Order {
order_id: order_id.clone(), listing_id, agent_id: listing.agent_id.clone(),
client: client_addr.clone(), agent_owner: listing.owner.clone(), milestones,
total_amount, denom: listing.price.denom, status: OrderStatus::Pending,
session_id: None, delivery_proof: None,
created_at: env.block.time.seconds(), updated_at: env.block.time.seconds(),
};
ORDERS.save(deps.storage, &order_id, &order)?;
ORDERS_BY_CLIENT.save(deps.storage, (&client_addr, &order_id), &true)?;
ORDERS_BY_AGENT.save(deps.storage, (&listing.agent_id, &order_id), &true)?;
ORDERS_BY_STATUS.save(deps.storage, (&OrderStatus::Pending.to_string(), &order_id), &true)?;
STATS.update(deps.storage, |mut s| { s.total_orders += 1; Ok(s) })?;
Ok(Response::new().add_attribute("action", "create_order").add_attribute("order_id", &order_id))
}
fn execute_accept_order(
deps: DepsMut, env: Env, info: MessageInfo, order_id: String,
) -> Result<Response, ContractError> {
let mut order = ORDERS.load(deps.storage, &order_id)
.map_err(|_| ContractError::OrderNotFound { order_id: order_id.clone() })?;
if info.sender != order.agent_owner { return Err(ContractError::Unauthorized); }
if !matches!(order.status, OrderStatus::Pending) { return Err(ContractError::OrderNotActive { order_id }); }
order.status = OrderStatus::Active; order.updated_at = env.block.time.seconds();
ORDERS.save(deps.storage, &order_id, &order)?;
ORDERS_BY_STATUS.remove(deps.storage, (&OrderStatus::Pending.to_string(), &order_id));
ORDERS_BY_STATUS.save(deps.storage, (&OrderStatus::Active.to_string(), &order_id), &true)?;
Ok(Response::new().add_attribute("action", "accept_order").add_attribute("order_id", &order_id))
}
fn execute_complete_order(
deps: DepsMut, env: Env, info: MessageInfo, order_id: String,
) -> Result<Response, ContractError> {
let mut order = ORDERS.load(deps.storage, &order_id)
.map_err(|_| ContractError::OrderNotFound { order_id: order_id.clone() })?;
if info.sender != order.client { return Err(ContractError::Unauthorized); }
if !matches!(order.status, OrderStatus::Delivered) { return Err(ContractError::OrderNotActive { order_id }); }
order.status = OrderStatus::Completed; order.updated_at = env.block.time.seconds();
ORDERS.save(deps.storage, &order_id, &order)?;
ORDERS_BY_STATUS.remove(deps.storage, (&OrderStatus::Delivered.to_string(), &order_id));
ORDERS_BY_STATUS.save(deps.storage, (&OrderStatus::Completed.to_string(), &order_id), &true)?;
LISTINGS.update(deps.storage, &order.listing_id, |l| { let mut l = l.unwrap(); l.completed_orders += 1; Ok(l) })?;
AGENT_RATING.update(deps.storage, &order.agent_id, |r| {
let mut r = r.unwrap_or(AgentRating { agent_id: order.agent_id.clone(), total_reviews: 0, average_rating: 0, total_orders: 0, completed_orders: 0 });
r.total_orders += 1; r.completed_orders += 1; Ok(r)
})?;
STATS.update(deps.storage, |mut s| { s.completed_orders += 1; s.total_volume.amount += order.total_amount; Ok(s) })?;
Ok(Response::new().add_attribute("action", "complete_order").add_attribute("order_id", &order_id))
}
fn execute_admin_remove_listing(
deps: DepsMut, env: Env, info: MessageInfo, listing_id: String,
) -> Result<Response, ContractError> {
let config = CONFIG.load(deps.storage)?;
if info.sender != config.admin { return Err(ContractError::Unauthorized); }
let mut listing = LISTINGS.load(deps.storage, &listing_id)
.map_err(|_| ContractError::ListingNotFound { listing_id: listing_id.clone() })?;
let old = listing.status.clone();
listing.status = ListingStatus::AdminRemoved; listing.updated_at = env.block.time.seconds();
LISTINGS.save(deps.storage, &listing_id, &listing)?;
LISTINGS_BY_STATUS.remove(deps.storage, (&old.to_string(), &listing_id));
LISTINGS_BY_STATUS.save(deps.storage, (&ListingStatus::AdminRemoved.to_string(), &listing_id), &true)?;
STATS.update(deps.storage, |mut s| { s.active_listings = s.active_listings.saturating_sub(1); Ok(s) })?;
Ok(Response::new().add_attribute("action", "admin_remove_listing").add_attribute("listing_id", &listing_id))
}
fn execute_admin_update_fee(
deps: DepsMut, _env: Env, info: MessageInfo, new_fee_percent: u64,
) -> Result<Response, ContractError> {
let config = CONFIG.load(deps.storage)?;
if info.sender != config.admin { return Err(ContractError::Unauthorized); }
if new_fee_percent > 1000 { return Err(ContractError::FeeTooHigh { fee: new_fee_percent }); }
CONFIG.update(deps.storage, |mut c| { c.platform_fee_percent = new_fee_percent; Ok(c) })?;
Ok(Response::new().add_attribute("action", "admin_update_fee").add_attribute("new_fee_percent", new_fee_percent.to_string()))
}
2.6 contract.rs -- Query 实现
#[entry_point]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::GetListing { listing_id } => to_json_binary(&query_listing(deps, listing_id)?),
QueryMsg::ListListings { start_after, limit, filters } => to_json_binary(&query_list_listings(deps, start_after, limit, filters)?),
QueryMsg::GetListingsByAgent { agent_id, status, start_after, limit } => to_json_binary(&query_listings_by_agent(deps, agent_id, status, start_after, limit)?),
QueryMsg::GetListingsByOwner { owner, start_after, limit } => to_json_binary(&query_listings_by_owner(deps, owner, start_after, limit)?),
QueryMsg::GetListingsByCategory { category, start_after, limit } => to_json_binary(&query_listings_by_category(deps, category, start_after, limit)?),
QueryMsg::SearchListings { query, categories, min_price, max_price, pricing_model, min_rating, start_after, limit } =>
to_json_binary(&query_search_listings(deps, query, categories, min_price, max_price, pricing_model, min_rating, start_after, limit)?),
QueryMsg::GetReviews { listing_id, start_after, limit } => to_json_binary(&query_reviews(deps, listing_id, start_after, limit)?),
QueryMsg::GetOrder { order_id } => to_json_binary(&query_order(deps, order_id)?),
QueryMsg::GetOrdersByClient { client, status, start_after, limit } => to_json_binary(&query_orders_by_client(deps, client, status, start_after, limit)?),
QueryMsg::GetOrdersByAgent { agent_id, status, start_after, limit } => to_json_binary(&query_orders_by_agent(deps, agent_id, status, start_after, limit)?),
QueryMsg::GetMarketplaceStats {} => to_json_binary(&query_stats(deps)?),
QueryMsg::GetConfig {} => to_json_binary(&CONFIG.load(deps.storage)?),
}
}
fn query_listing(deps: Deps, listing_id: String) -> StdResult<Listing> {
LISTINGS.load(deps.storage, &listing_id)
}
fn query_list_listings(
deps: Deps, start_after: Option<String>, limit: Option<u32>, filters: Option<ListingFilters>,
) -> StdResult<ListingsResponse> {
let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let listings: Vec<Listing> = match filters {
Some(f) => {
let mut results = vec![];
let status = f.status.unwrap_or(ListingStatus::Active);
for entry in LISTINGS_BY_STATUS.prefix(&status.to_string()).keys(deps.storage, None, None, CwOrder::Ascending) {
let lid = entry?;
if let Some(ref sa) = start_after { if lid <= *sa { continue; } }
if results.len() >= limit as usize { break; }
if let Ok(l) = LISTINGS.load(deps.storage, &lid) {
let mut matches = true;
if let Some(ref pm) = f.pricing_model { if l.pricing_model != *pm { matches = false; } }
if let Some(ref cats) = f.categories { if !cats.iter().any(|c| l.categories.contains(c)) { matches = false; } }
if let Some(min_r) = f.min_rating { if l.average_rating < min_r { matches = false; } }
if let Some(min_p) = f.min_price { if l.price.amount < min_p { matches = false; } }
if let Some(max_p) = f.max_price { if l.price.amount > max_p { matches = false; } }
if matches { results.push(l); }
}
}
results
}
None => {
let mut results = vec![];
for entry in LISTINGS.range(deps.storage, None, None, CwOrder::Ascending) {
let (lid, listing) = entry?;
if let Some(ref sa) = start_after { if lid <= *sa { continue; } }
if results.len() >= limit as usize { break; }
results.push(listing);
}
results
}
};
Ok(ListingsResponse { listings })
}
fn query_search_listings(
deps: Deps, query: String, categories: Option<Vec<String>>,
min_price: Option<Uint128>, max_price: Option<Uint128>,
pricing_model: Option<PricingModel>, min_rating: Option<u8>,
start_after: Option<String>, limit: Option<u32>,
) -> StdResult<ListingsResponse> {
let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let q = query.to_lowercase();
let mut results = vec![];
for entry in LISTINGS.range(deps.storage, None, None, CwOrder::Ascending) {
let (lid, listing) = entry?;
if let Some(ref sa) = start_after { if lid <= *sa { continue; } }
if results.len() >= limit as usize { break; }
if !matches!(listing.status, ListingStatus::Active) { continue; }
let tm = listing.title.to_lowercase().contains(&q);
let dm = listing.description.to_lowercase().contains(&q);
let tg = listing.tags.iter().any(|t| t.to_lowercase().contains(&q));
if !tm && !dm && !tg { continue; }
if let Some(ref cats) = categories { if !cats.iter().any(|c| listing.categories.contains(c)) { continue; } }
if let Some(min) = min_price { if listing.price.amount < min { continue; } }
if let Some(max) = max_price { if listing.price.amount > max { continue; } }
if let Some(ref pm) = pricing_model { if listing.pricing_model != *pm { continue; } }
if let Some(mr) = min_rating { if listing.average_rating < mr { continue; } }
results.push(listing);
}
Ok(ListingsResponse { listings: results })
}
fn query_reviews(deps: Deps, listing_id: String, start_after: Option<String>, limit: Option<u32>) -> StdResult<ReviewsResponse> {
let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let mut reviews = vec![];
for entry in REVIEWS_BY_LISTING.prefix(&listing_id).keys(deps.storage, None, None, CwOrder::Ascending) {
let rid = entry?;
if let Some(ref sa) = start_after { if rid <= *sa { continue; } }
if reviews.len() >= limit as usize { break; }
if let Ok(r) = REVIEWS.load(deps.storage, &rid) { reviews.push(r); }
}
Ok(ReviewsResponse { reviews })
}
fn query_order(deps: Deps, order_id: String) -> StdResult<Order> { ORDERS.load(deps.storage, &order_id) }
fn query_orders_by_client(deps: Deps, client: String, status: Option<OrderStatus>, start_after: Option<String>, limit: Option<u32>) -> StdResult<OrdersResponse> {
let addr = deps.api.addr_validate(&client)?;
let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let mut orders = vec![];
for entry in ORDERS_BY_CLIENT.prefix(&addr).keys(deps.storage, None, None, CwOrder::Ascending) {
let oid = entry?;
if let Some(ref sa) = start_after { if oid <= *sa { continue; } }
if orders.len() >= limit as usize { break; }
if let Ok(o) = ORDERS.load(deps.storage, &oid) {
if let Some(ref s) = status { if o.status == *s { orders.push(o); } } else { orders.push(o); }
}
}
Ok(OrdersResponse { orders })
}
fn query_orders_by_agent(deps: Deps, agent_id: String, status: Option<OrderStatus>, start_after: Option<String>, limit: Option<u32>) -> StdResult<OrdersResponse> {
let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let mut orders = vec![];
for entry in ORDERS_BY_AGENT.prefix(&agent_id).keys(deps.storage, None, None, CwOrder::Ascending) {
let oid = entry?;
if let Some(ref sa) = start_after { if oid <= *sa { continue; } }
if orders.len() >= limit as usize { break; }
if let Ok(o) = ORDERS.load(deps.storage, &oid) {
if let Some(ref s) = status { if o.status == *s { orders.push(o); } } else { orders.push(o); }
}
}
Ok(OrdersResponse { orders })
}
fn query_stats(deps: Deps) -> StdResult<MarketplaceStats> { STATS.load(deps.storage) }
2.7 lib.rs
pub mod contract;
pub mod error;
pub mod msg;
pub mod state;
pub use crate::error::ContractError;
2.8 examples/schema.rs
use cosmwasm_schema::write_api;
use marketplace_v1::msg::{ExecuteMsg, InstantiateMsg, QueryMsg};
fn main() {
write_api! { instantiate: InstantiateMsg, execute: ExecuteMsg, query: QueryMsg }
}
3. Part 2: 后端 API 服务
3.1 项目结构
backend/
├── package.json
├── tsconfig.json
├── .env.example
├── docker-compose.yml
├── Dockerfile
└── src/
├── index.ts
├── config.ts
├── types/
│ ├── marketplace.ts
│ ├── agent-api.ts
│ └── blockchain.ts
├── routes/
│ ├── marketplace.ts
│ ├── agents.ts
│ ├── transactions.ts
│ └── admin.ts
└── services/
├── cosmwasm.ts
├── websocket.ts
└── agent-proxy.ts
3.2 package.json
{
"name": "ai-agent-marketplace-api",
"version": "1.0.0",
"description": "AI Agent Marketplace Backend API for MSG Chain",
"main": "dist/index.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@cosmjs/cosmwasm-stargate": "^0.32.0",
"@cosmjs/stargate": "^0.32.0",
"@cosmjs/proto-signing": "^0.32.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.4",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
"socket.io": "^4.7.2",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/morgan": "^1.9.9",
"@types/node": "^20.10.4",
"ts-node-dev": "^2.0.0",
"typescript": "^5.3.3"
}
}
3.3 tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
3.4 .env.example
RPC_ENDPOINT=http://localhost:26657
REST_ENDPOINT=http://localhost:1317
MARKETPLACE_CONTRACT=
REGISTRY_CONTRACT=
PAYMENT_CONTRACT=
DID_CONTRACT=
CONSTITUTION_CONTRACT=
PORT=3000
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173
AGENT_API_BASE_URL=http://agent-runtime:8080
ADMIN_MNEMONIC=
ADMIN_ADDRESS=
3.5 src/config.ts
import dotenv from "dotenv";
dotenv.config();
export const config = {
chain: {
rpcEndpoint: process.env.RPC_ENDPOINT || "http://localhost:26657",
chainId: "msg-chain-1",
prefix: "msg",
gasPrice: { average: "1000000000", denom: "attoMSG" },
decimals: 18,
},
contracts: {
marketplace: process.env.MARKETPLACE_CONTRACT || "",
registry: process.env.REGISTRY_CONTRACT || "",
payment: process.env.PAYMENT_CONTRACT || "",
did: process.env.DID_CONTRACT || "",
constitution: process.env.CONSTITUTION_CONTRACT || "",
},
server: {
port: parseInt(process.env.PORT || "3000", 10),
env: process.env.NODE_ENV || "development",
corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173",
},
agentApi: { baseUrl: process.env.AGENT_API_BASE_URL || "http://agent-runtime:8080" },
admin: { mnemonic: process.env.ADMIN_MNEMONIC || "", address: process.env.ADMIN_ADDRESS || "" },
};
3.6 src/types/blockchain.ts
export interface Coin { denom: string; amount: string; }
export interface Fee { amount: Coin[]; gas: string; }
export interface TransactionResult { transactionHash: string; events: { type: string; attributes: { key: string; value: string }[] }[]; }
export enum AgentStatus { Active = "active", Inactive = "inactive", Suspended = "suspended", Deregistered = "deregistered" }
export enum SessionStatus { Pending = "pending", Active = "active", Completed = "completed", Disputed = "disputed", Cancelled = "cancelled" }
export enum OrderStatus { Pending = "pending", Active = "active", Delivered = "delivered", Completed = "completed", Cancelled = "cancelled", Disputed = "disputed" }
export interface Milestone { title: string; description?: string; amount: string; completed: boolean; }
export interface AgentInfo { agent_id: string; did_id: string; owner: string; name: string; description?: string; endpoint?: string; agent_type: string; capabilities: string[]; status: AgentStatus; }
export interface SessionInfo { session_id: string; agent_id: string; client: string; total_amount: string; denom: string; status: SessionStatus; milestones: Milestone[]; created_at: number; expiry: number; }
export interface DIDInfo { did_id: string; controller: string; verification_methods: { id: string; type: string; controller: string }[]; services: { id: string; type: string; service_endpoint: string }[]; active: boolean; }
export interface ConstitutionInfo { preamble: string; rules: { rule_id: string; rule_text: string; category: string; priority: number; active: boolean }[]; active: boolean; }
3.7 src/types/marketplace.ts
export interface MarketplaceListing {
listing_id: string; agent_id: string; owner: string; title: string; description: string;
price: { denom: string; amount: string };
pricing_model: "fixed" | "hourly" | "milestone";
categories: string[]; tags: string[];
delivery_days?: number; terms?: string;
status: "active" | "paused" | "removed" | "admin_removed";
total_reviews: number; average_rating: number;
total_orders: number; completed_orders: number;
created_at: number; updated_at: number;
}
export interface Review { review_id: string; listing_id: string; session_id: string; reviewer: string; rating: number; review_text?: string; reply?: string; created_at: number; }
export interface OrderMilestone { title: string; description: string; amount: string; due_days: number; }
export interface Order {
order_id: string; listing_id: string; agent_id: string; client: string; agent_owner: string;
milestones: OrderMilestone[]; total_amount: string; denom: string;
status: "pending" | "active" | "delivered" | "completed" | "cancelled" | "disputed";
session_id?: string; delivery_proof?: string; created_at: number; updated_at: number;
}
export interface MarketplaceStats {
total_listings: number; active_listings: number; total_orders: number; completed_orders: number;
total_reviews: number; total_agents: number; total_volume: { denom: string; amount: string };
}
3.8 src/types/agent-api.ts
export interface AgentQueryRequest { query: string; agent_id: string; context?: Record<string, unknown>; session_id?: string; }
export interface AgentQueryResponse { response: string; agent_id: string; confidence?: number; actions?: { type: string; params: Record<string, unknown> }[]; metadata?: Record<string, unknown>; }
export interface AgentStatusResponse { agent_id: string; status: "online" | "offline" | "busy" | "error"; uptime: number; current_load: number; last_heartbeat: string; }
export interface AgentHistoryEntry { timestamp: string; action: string; query?: string; response?: string; status: string; }
export interface AgentHistoryResponse { agent_id: string; history: AgentHistoryEntry[]; total: number; }
export interface EventSubscriptionRequest { agent_ids: string[]; event_types: string[]; webhook_url?: string; }
export interface EventMessage { event_id: string; event_type: string; agent_id: string; timestamp: string; data: Record<string, unknown>; }
export interface OracleRequest { agent_id: string; request_type: string; params: Record<string, unknown>; timeout?: number; }
export interface OracleResult { request_id: string; status: "pending" | "completed" | "failed"; result?: unknown; proof?: string; verified?: boolean; }
export interface WalletCreateRequest { agent_id: string; key_type?: string; }
export interface WalletInfo { wallet_id: string; address: string; key_type: string; created_at: string; }
export interface WalletBalance { wallet_id: string; address: string; balances: { denom: string; amount: string }[]; }
export interface MPCRequest { agent_ids: string[]; message: string; threshold: number; }
export interface MPCSignResult { request_id: string; signature: string; signers: string[]; completed: boolean; }
3.9 src/services/agent-proxy.ts
import axios, { AxiosInstance } from "axios";
import { config } from "../config";
import {
AgentQueryRequest, AgentQueryResponse, AgentStatusResponse, AgentHistoryResponse,
EventSubscriptionRequest, OracleRequest, OracleResult,
WalletCreateRequest, WalletInfo, WalletBalance, MPCRequest, MPCSignResult,
} from "../types/agent-api";
class AgentProxyService {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: config.agentApi.baseUrl,
timeout: 30000,
headers: { "Content-Type": "application/json" },
});
}
// X-MSG-Stub=true
async queryAgent(req: AgentQueryRequest): Promise<AgentQueryResponse> {
const { data } = await this.client.post<AgentQueryResponse>("/api/v1/agent/query", req);
return data;
}
// X-MSG-Stub=true
async getAgentStatus(agentId: string): Promise<AgentStatusResponse> {
const { data } = await this.client.get<AgentStatusResponse>("/api/v1/agent/" + agentId + "/status");
return data;
}
// X-MSG-Stub=true
async getAgentHistory(agentId: string): Promise<AgentHistoryResponse> {
const { data } = await this.client.get<AgentHistoryResponse>("/api/v1/agent/" + agentId + "/history");
return data;
}
// X-MSG-Stub=true
async subscribeToEvents(req: EventSubscriptionRequest): Promise<{ subscription_id: string }> {
const { data } = await this.client.post("/api/v1/agent/events/subscribe", req);
return data;
}
// X-MSG-Stub=true
async unsubscribeFromEvents(id: string): Promise<void> {
await this.client.delete("/api/v1/agent/events/subscribe/" + id);
}
// X-MSG-Stub=true
async requestOracle(req: OracleRequest): Promise<{ request_id: string }> {
const { data } = await this.client.post("/api/v1/agent/oracle/request", req);
return data;
}
// X-MSG-Stub=true
async getOracleResult(requestId: string): Promise<OracleResult> {
const { data } = await this.client.get<OracleResult>("/api/v1/agent/oracle/result/" + requestId);
return data;
}
// X-MSG-Stub=true
async createWallet(req: WalletCreateRequest): Promise<WalletInfo> {
const { data } = await this.client.post<WalletInfo>("/api/v1/agent/wallet/create", req);
return data;
}
// X-MSG-Stub=true
async getWalletBalance(walletId: string): Promise<WalletBalance> {
const { data } = await this.client.get<WalletBalance>("/api/v1/agent/wallet/" + walletId + "/balance");
return data;
}
// X-MSG-Stub=true
async mpcSign(req: MPCRequest): Promise<{ request_id: string }> {
const { data } = await this.client.post("/api/v1/agent/mpc/sign", req);
return data;
}
// X-MSG-Stub=true
async mpcAggregate(requestId: string): Promise<MPCSignResult> {
const { data } = await this.client.get<MPCSignResult>("/api/v1/agent/mpc/aggregate/" + requestId);
return data;
}
// X-MSG-Stub=true
async createPaymentSession(req: {
agent_id: string; client: string; total_amount: string;
denom: string; milestones: { title: string; amount: string }[]; expiry: number;
}): Promise<{ session_id: string }> {
const { data } = await this.client.post("/api/v1/agent/payment/create-session", req);
return data;
}
// X-MSG-Stub=true
async closePaymentSession(sessionId: string, reason?: string): Promise<void> {
await this.client.post("/api/v1/agent/payment/close-session/" + sessionId, { reason });
}
}
export const agentProxyService = new AgentProxyService();
3.10 src/services/cosmwasm.ts
import { CosmWasmClient, SigningCosmWasmClient, GasPrice } from "@cosmjs/cosmwasm-stargate";
import { DirectSecp256k1HdWallet, OfflineDirectSigner } from "@cosmjs/proto-signing";
import { calculateFee } from "@cosmjs/stargate";
import { config } from "../config";
class CosmWasmService {
private client: CosmWasmClient | null = null;
private signingClient: SigningCosmWasmClient | null = null;
private adminSigner: OfflineDirectSigner | null = null;
async getClient(): Promise<CosmWasmClient> {
if (!this.client) this.client = await CosmWasmClient.connect(config.chain.rpcEndpoint);
return this.client;
}
async getSigningClient(): Promise<SigningCosmWasmClient> {
if (!this.signingClient) {
this.signingClient = await SigningCosmWasmClient.connectWithSigner(
config.chain.rpcEndpoint, await this.getAdminSigner(),
{ gasPrice: GasPrice.fromString(config.chain.gasPrice.average + config.chain.denom) }
);
}
return this.signingClient;
}
private async getAdminSigner(): Promise<OfflineDirectSigner> {
if (!this.adminSigner) {
if (!config.admin.mnemonic) throw new Error("Admin mnemonic not configured");
this.adminSigner = await DirectSecp256k1HdWallet.fromMnemonic(
config.admin.mnemonic, { prefix: config.chain.prefix }
);
}
return this.adminSigner;
}
async queryContract<T>(contractAddress: string, query: Record<string, unknown>): Promise<T> {
return (await this.getClient()).queryContractSmart(contractAddress, query) as T;
}
async executeContract(
contractAddress: string, msg: Record<string, unknown>,
funds: { denom: string; amount: string }[] = []
) {
const client = await this.getSigningClient();
const addr = (await this.getAdminSigner()).getAccounts()[0].address;
const fee = calculateFee(200000, GasPrice.fromString(config.chain.gasPrice.average + config.chain.denom));
return await client.execute(addr, contractAddress, msg, fee, undefined, funds);
}
}
export const cosmwasmService = new CosmWasmService();
3.11 src/services/websocket.ts
import { Server as HttpServer } from "http";
import { Server as SocketIOServer, Socket } from "socket.io";
import { config } from "../config";
class WebSocketEventHub {
private io: SocketIOServer | null = null;
initialize(httpServer: HttpServer): void {
this.io = new SocketIOServer(httpServer, {
cors: { origin: config.server.corsOrigin, methods: ["GET", "POST"] },
pingTimeout: 60000, pingInterval: 25000,
});
this.io.on("connection", (socket: Socket) => {
console.log("[WS] Client connected:", socket.id);
socket.on("subscribe", (rooms: string[]) => { rooms.forEach((r) => socket.join(r)); socket.emit("subscribed", { rooms }); });
socket.on("unsubscribe", (rooms: string[]) => { rooms.forEach((r) => socket.leave(r)); });
socket.on("disconnect", () => console.log("[WS] Client disconnected:", socket.id));
});
}
broadcast(event: string, message: Record<string, unknown>): void {
if (this.io) this.io.emit(event, message);
}
emitMarketplaceEvent(eventType: string, data: Record<string, unknown>): void {
this.broadcast("marketplace", { event_type: eventType, data, timestamp: Date.now() });
}
emitOrderEvent(orderId: string, status: string, data: Record<string, unknown>): void {
this.broadcast("order", { event_type: "order:" + status, order_id: orderId, data, timestamp: Date.now() });
}
emitAgentEvent(agentId: string, eventType: string, data: Record<string, unknown>): void {
this.broadcast("agent", { event_type: "agent:" + eventType, agent_id: agentId, data, timestamp: Date.now() });
}
}
export const wsEventHub = new WebSocketEventHub();
3.12 src/routes/marketplace.ts
import { Router, Request, Response, NextFunction } from "express";
import { cosmwasmService } from "../services/cosmwasm";
import { config } from "../config";
const router = Router();
router.get("/stats", async (_req: Request, res: Response, next: NextFunction) => {
try { const s = await cosmwasmService.queryContract(config.contracts.marketplace, { get_marketplace_stats: {} }); res.json({ success: true, data: s }); }
catch (e) { next(e); }
});
router.get("/config", async (_req: Request, res: Response, next: NextFunction) => {
try { const c = await cosmwasmService.queryContract(config.contracts.marketplace, { get_config: {} }); res.json({ success: true, data: c }); }
catch (e) { next(e); }
});
router.get("/listings", async (req: Request, res: Response, next: NextFunction) => {
try {
const { start_after, limit, status, pricing_model, categories, min_price, max_price } = req.query;
const filters: Record<string, unknown> = {};
if (status) filters.status = status;
if (pricing_model) filters.pricing_model = pricing_model;
if (categories) filters.categories = (categories as string).split(",");
if (min_price) filters.min_price = min_price;
if (max_price) filters.max_price = max_price;
const r = await cosmwasmService.queryContract<{ listings: unknown[] }>(config.contracts.marketplace, {
list_listings: { start_after, limit: limit ? parseInt(limit as string) : undefined, filters: Object.keys(filters).length > 0 ? filters : undefined }
});
res.json({ success: true, data: r.listings });
} catch (e) { next(e); }
});
router.get("/listings/search", async (req: Request, res: Response, next: NextFunction) => {
try {
const { q, categories, min_price, max_price, pricing_model, min_rating } = req.query;
const r = await cosmwasmService.queryContract<{ listings: unknown[] }>(config.contracts.marketplace, {
search_listings: {
query: q || "", categories: categories ? (categories as string).split(",") : undefined,
min_price, max_price, pricing_model, min_rating: min_rating ? parseInt(min_rating as string) : undefined,
}
});
res.json({ success: true, data: r.listings });
} catch (e) { next(e); }
});
router.get("/listings/:id", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await cosmwasmService.queryContract(config.contracts.marketplace, { get_listing: { listing_id: req.params.id } }); res.json({ success: true, data: r }); }
catch (e) { next(e); }
});
router.get("/listings/:id/reviews", async (req: Request, res: Response, next: NextFunction) => {
try {
const r = await cosmwasmService.queryContract<{ reviews: unknown[] }>(config.contracts.marketplace, { get_reviews: { listing_id: req.params.id } });
res.json({ success: true, data: r.reviews });
} catch (e) { next(e); }
});
router.get("/orders/:id", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await cosmwasmService.queryContract(config.contracts.marketplace, { get_order: { order_id: req.params.id } }); res.json({ success: true, data: r }); }
catch (e) { next(e); }
});
router.get("/orders/by-client/:address", async (req: Request, res: Response, next: NextFunction) => {
try {
const r = await cosmwasmService.queryContract<{ orders: unknown[] }>(config.contracts.marketplace, { get_orders_by_client: { client: req.params.address } });
res.json({ success: true, data: r.orders });
} catch (e) { next(e); }
});
router.get("/orders/by-agent/:agentId", async (req: Request, res: Response, next: NextFunction) => {
try {
const r = await cosmwasmService.queryContract<{ orders: unknown[] }>(config.contracts.marketplace, { get_orders_by_agent: { agent_id: req.params.agentId } });
res.json({ success: true, data: r.orders });
} catch (e) { next(e); }
});
export default router;
3.13 src/routes/agents.ts
import { Router, Request, Response, NextFunction } from "express";
import { agentProxyService } from "../services/agent-proxy";
const router = Router();
router.post("/query", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await agentProxyService.queryAgent(req.body); res.json({ success: true, data: r }); } catch (e) { next(e); }
});
router.get("/:id/status", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await agentProxyService.getAgentStatus(req.params.id); res.json({ success: true, data: r }); } catch (e) { next(e); }
});
router.get("/:id/history", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await agentProxyService.getAgentHistory(req.params.id); res.json({ success: true, data: r }); } catch (e) { next(e); }
});
router.post("/events/subscribe", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await agentProxyService.subscribeToEvents(req.body); res.json({ success: true, data: r }); } catch (e) { next(e); }
});
router.delete("/events/subscribe/:id", async (req: Request, res: Response, next: NextFunction) => {
try { await agentProxyService.unsubscribeFromEvents(req.params.id); res.json({ success: true }); } catch (e) { next(e); }
});
router.post("/oracle/request", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await agentProxyService.requestOracle(req.body); res.json({ success: true, data: r }); } catch (e) { next(e); }
});
router.get("/oracle/result/:id", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await agentProxyService.getOracleResult(req.params.id); res.json({ success: true, data: r }); } catch (e) { next(e); }
});
router.post("/wallet/create", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await agentProxyService.createWallet(req.body); res.json({ success: true, data: r }); } catch (e) { next(e); }
});
router.get("/wallet/:id/balance", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await agentProxyService.getWalletBalance(req.params.id); res.json({ success: true, data: r }); } catch (e) { next(e); }
});
router.post("/mpc/sign", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await agentProxyService.mpcSign(req.body); res.json({ success: true, data: r }); } catch (e) { next(e); }
});
router.get("/mpc/aggregate/:id", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await agentProxyService.mpcAggregate(req.params.id); res.json({ success: true, data: r }); } catch (e) { next(e); }
});
router.post("/payment/create-session", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await agentProxyService.createPaymentSession(req.body); res.json({ success: true, data: r }); } catch (e) { next(e); }
});
router.post("/payment/close-session/:id", async (req: Request, res: Response, next: NextFunction) => {
try { await agentProxyService.closePaymentSession(req.params.id, req.body.reason); res.json({ success: true }); } catch (e) { next(e); }
});
export default router;
3.14 src/routes/transactions.ts
import { Router, Request, Response, NextFunction } from "express";
import { cosmwasmService } from "../services/cosmwasm";
import { config } from "../config";
const router = Router();
router.post("/create-listing", async (req: Request, res: Response, next: NextFunction) => {
try {
const { agent_id, title, description, price, denom, pricing_model, categories, tags } = req.body;
const r = await cosmwasmService.executeContract(config.contracts.marketplace, {
create_listing: { agent_id, title, description, price: { denom: denom || "umsg", amount: price }, pricing_model, categories, tags: tags || [] }
});
res.json({ success: true, data: { tx_hash: r.transactionHash } });
} catch (e) { next(e); }
});
router.post("/submit-review", async (req: Request, res: Response, next: NextFunction) => {
try {
const { listing_id, session_id, rating, review_text } = req.body;
const r = await cosmwasmService.executeContract(config.contracts.marketplace, { submit_review: { listing_id, session_id, rating, review_text } });
res.json({ success: true, data: { tx_hash: r.transactionHash } });
} catch (e) { next(e); }
});
router.post("/create-order", async (req: Request, res: Response, next: NextFunction) => {
try {
const { listing_id, client, milestones, expiry } = req.body;
const r = await cosmwasmService.executeContract(config.contracts.marketplace, { create_order: { listing_id, client, milestones, expiry } });
res.json({ success: true, data: { tx_hash: r.transactionHash } });
} catch (e) { next(e); }
});
router.post("/accept-order", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await cosmwasmService.executeContract(config.contracts.marketplace, { accept_order: { order_id: req.body.order_id } }); res.json({ success: true, data: { tx_hash: r.transactionHash } }); }
catch (e) { next(e); }
});
router.post("/complete-order", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await cosmwasmService.executeContract(config.contracts.marketplace, { complete_order: { order_id: req.body.order_id } }); res.json({ success: true, data: { tx_hash: r.transactionHash } }); }
catch (e) { next(e); }
});
router.post("/mark-delivered", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await cosmwasmService.executeContract(config.contracts.marketplace, { mark_delivered: { order_id: req.body.order_id, delivery_proof: req.body.delivery_proof } }); res.json({ success: true, data: { tx_hash: r.transactionHash } }); }
catch (e) { next(e); }
});
router.post("/cancel-order", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await cosmwasmService.executeContract(config.contracts.marketplace, { cancel_order: { order_id: req.body.order_id, reason: req.body.reason } }); res.json({ success: true, data: { tx_hash: r.transactionHash } }); }
catch (e) { next(e); }
});
export default router;
3.15 src/routes/admin.ts
import { Router, Request, Response, NextFunction } from "express";
import { cosmwasmService } from "../services/cosmwasm";
import { config } from "../config";
const router = Router();
function requireAdmin(req: Request, res: Response, next: NextFunction) {
const key = req.headers["x-api-key"];
if (key !== process.env.ADMIN_API_KEY) return res.status(401).json({ success: false, error: "Unauthorized" });
next();
}
router.use(requireAdmin);
router.post("/listings/:id/remove", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await cosmwasmService.executeContract(config.contracts.marketplace, { admin_remove_listing: { listing_id: req.params.id, reason: req.body.reason || "Violates terms" } }); res.json({ success: true, data: { tx_hash: r.transactionHash } }); }
catch (e) { next(e); }
});
router.post("/config/fee", async (req: Request, res: Response, next: NextFunction) => {
try { const r = await cosmwasmService.executeContract(config.contracts.marketplace, { admin_update_fee: { new_fee_percent: req.body.fee_percent } }); res.json({ success: true, data: { tx_hash: r.transactionHash } }); }
catch (e) { next(e); }
});
router.get("/contracts/config", async (_req: Request, res: Response, next: NextFunction) => {
try {
const mc = await cosmwasmService.queryContract(config.contracts.marketplace, { get_config: {} });
const cc = await cosmwasmService.queryContract(config.contracts.constitution, { get_constitution: {} });
res.json({ success: true, data: { marketplace: mc, constitution: cc } });
} catch (e) { next(e); }
});
export default router;
3.16 src/index.ts
import express from "express";
import cors from "cors";
import helmet from "helmet";
import morgan from "morgan";
import http from "http";
import rateLimit from "express-rate-limit";
import { config } from "./config";
import { wsEventHub } from "./services/websocket";
import marketplaceRoutes from "./routes/marketplace";
import agentRoutes from "./routes/agents";
import adminRoutes from "./routes/admin";
import txRoutes from "./routes/transactions";
const app = express();
const server = http.createServer(app);
wsEventHub.initialize(server);
app.use(helmet());
app.use(cors({ origin: config.server.corsOrigin, credentials: true }));
app.use(morgan("dev"));
app.use(express.json({ limit: "10mb" }));
app.use("/api/", rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
app.get("/health", (_req, res) => res.json({ status: "ok", chain: config.chain.chainId, timestamp: Date.now() }));
app.use("/api/marketplace", marketplaceRoutes);
app.use("/api/agents", agentRoutes);
app.use("/api/admin", adminRoutes);
app.use("/api/tx", txRoutes);
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(500).json({ success: false, error: config.server.env === "production" ? "Internal error" : err.message });
});
app.use((_req, res) => res.status(404).json({ success: false, error: "Not found" }));
server.listen(config.server.port, () => {
console.log("AI Agent Marketplace API running on port " + config.server.port + " [" + config.chain.chainId + "]");
});
export default app;
3.17 Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json tsconfig.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 appuser
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
USER appuser
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]
4. Part 3: 前端应用
4.1 项目结构
frontend/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.js
├── postcss.config.js
├── index.html
├── Dockerfile
├── nginx.conf
└── src/
├── main.tsx
├── App.tsx
├── index.css
├── types/
│ ├── marketplace.ts
│ ├── agent.ts
│ └── chain.ts
├── hooks/
│ ├── useWallet.ts
│ ├── useMarketplace.ts
│ └── useOrders.ts
├── services/
│ ├── api.ts
│ └── wallet.ts
├── components/
│ ├── Layout.tsx
│ ├── WalletConnect.tsx
│ ├── ListingCard.tsx
│ ├── StarRating.tsx
│ ├── StatusBadge.tsx
│ ├── LoadingState.tsx
│ └── EmptyState.tsx
└── pages/
├── HomePage.tsx
├── MarketplacePage.tsx
├── AgentDetailPage.tsx
├── RegisterAgentPage.tsx
├── HireFlowPage.tsx
├── MyAgentsPage.tsx
├── MySessionsPage.tsx
├── DisputePage.tsx
├── AgentChatPage.tsx
└── AdminPage.tsx
4.2 package.json
{
"name": "ai-agent-marketplace-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview" },
"dependencies": {
"@cosmjs/cosmwasm-stargate": "^0.32.0",
"@cosmjs/stargate": "^0.32.0",
"@cosmjs/proto-signing": "^0.32.0",
"@keplr-wallet/types": "^0.12.50",
"@tanstack/react-query": "^5.13.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"react-hook-form": "^7.49.2",
"socket.io-client": "^4.7.2",
"lucide-react": "^0.298.0",
"clsx": "^2.0.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.8"
}
}
4.3 vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true } },
},
});
4.4 tailwind.config.js
/** @type {import("tailwindcss").Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
primary: { 50: "#eef2ff", 100: "#e0e7ff", 200: "#c7d2fe", 300: "#a5b4fc", 400: "#818cf8", 500: "#6366f1", 600: "#4f46e5", 700: "#4338ca", 800: "#3730a3", 900: "#312e81" },
accent: { 50: "#f0fdf4", 100: "#dcfce7", 200: "#bbf7d0", 300: "#86efac", 400: "#4ade80", 500: "#22c55e", 600: "#16a34a", 700: "#15803d", 800: "#166534", 900: "#14532d" },
},
},
},
plugins: [],
};
4.5 src/types/marketplace.ts (前端)
export interface MarketplaceListing {
listing_id: string; agent_id: string; owner: string; title: string; description: string;
price: { denom: string; amount: string };
pricing_model: "fixed" | "hourly" | "milestone";
categories: string[]; tags: string[]; delivery_days?: number; terms?: string;
status: "active" | "paused" | "removed" | "admin_removed";
total_reviews: number; average_rating: number; total_orders: number; completed_orders: number;
created_at: number; updated_at: number;
}
export interface Review { review_id: string; listing_id: string; session_id: string; reviewer: string; rating: number; review_text?: string; reply?: string; created_at: number; }
export interface OrderMilestone { title: string; description: string; amount: string; due_days: number; }
export interface Order {
order_id: string; listing_id: string; agent_id: string; client: string; agent_owner: string;
milestones: OrderMilestone[]; total_amount: string; denom: string;
status: "pending" | "active" | "delivered" | "completed" | "cancelled" | "disputed";
session_id?: string; delivery_proof?: string; created_at: number; updated_at: number;
}
export interface MarketplaceStats {
total_listings: number; active_listings: number; total_orders: number; completed_orders: number;
total_reviews: number; total_agents: number; total_volume: { denom: string; amount: string };
}
4.6 src/types/chain.ts
export interface ChainInfo {
chainId: string; chainName: string; rpc: string; rest: string; bech32Prefix: string;
currencies: { coinDenom: string; coinMinimalDenom: string; coinDecimals: number }[];
feeCurrencies: { coinDenom: string; coinMinimalDenom: string; coinDecimals: number }[];
stakeCurrency: { coinDenom: string; coinMinimalDenom: string; coinDecimals: number };
bip44: { coinType: number }; features: string[];
}
export const MSG_CHAIN_INFO: ChainInfo = {
chainId: "msg-chain-1",
chainName: "MSG Chain",
rpc: "http://localhost:26657",
rest: "http://localhost:1317",
bech32Prefix: "msg",
currencies: [{ coinDenom: "MSG", coinMinimalDenom: "umsg", coinDecimals: 18 }],
feeCurrencies: [{ coinDenom: "MSG", coinMinimalDenom: "umsg", coinDecimals: 18 }],
stakeCurrency: { coinDenom: "MSG", coinMinimalDenom: "umsg", coinDecimals: 18 },
bip44: { coinType: 118 },
features: ["cosmwasm"],
};
4.7 src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
body { font-family: "Inter", system-ui, sans-serif; @apply bg-gray-50 text-gray-900 antialiased; }
@layer components {
.btn-primary { @apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed; }
.btn-secondary { @apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors; }
.card { @apply bg-white rounded-xl shadow-sm border border-gray-100 p-6; }
.input-field { @apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-colors; }
}
4.8 src/services/api.ts
import { MarketplaceListing, Review, Order, MarketplaceStats } from "../types/marketplace";
import { AgentQueryRequest, AgentQueryResponse, AgentStatusResponse } from "../types/agent";
const BASE = "/api";
async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, { headers: { "Content-Type": "application/json" }, ...options });
if (!res.ok) { const e = await res.json().catch(() => ({ error: "Network error" })); throw new Error(e.error || "HTTP " + res.status); }
const d = await res.json();
return d.data as T;
}
export const getMarketplaceStats = () => fetchApi<MarketplaceStats>("/marketplace/stats");
export const getListings = (p?: Record<string, string>) => fetchApi<MarketplaceListing[]>("/marketplace/listings" + (p ? "?" + new URLSearchParams(p).toString() : ""));
export const searchListings = (q: string) => fetchApi<MarketplaceListing[]>("/marketplace/listings/search?q=" + encodeURIComponent(q));
export const getListing = (id: string) => fetchApi<MarketplaceListing>("/marketplace/listings/" + id);
export const getListingReviews = (id: string) => fetchApi<Review[]>("/marketplace/listings/" + id + "/reviews");
export const getOrder = (id: string) => fetchApi<Order>("/marketplace/orders/" + id);
export const getOrdersByClient = (addr: string) => fetchApi<Order[]>("/marketplace/orders/by-client/" + addr);
export const getOrdersByAgent = (id: string) => fetchApi<Order[]>("/marketplace/orders/by-agent/" + id);
export const postTx = (path: string, body: Record<string, unknown>) =>
fetchApi<{ tx_hash: string }>(path, { method: "POST", body: JSON.stringify(body) });
export const queryAgent = (req: AgentQueryRequest) =>
fetchApi<AgentQueryResponse>("/agents/query", { method: "POST", body: JSON.stringify(req) });
export const getAgentStatus = (id: string) => fetchApi<AgentStatusResponse>("/agents/" + id + "/status");
4.9 src/services/wallet.ts
import { SigningCosmWasmClient, GasPrice } from "@cosmjs/cosmwasm-stargate";
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";
import { MSG_CHAIN_INFO } from "../types/chain";
declare global {
interface Window {
keplr?: {
enable(chainId: string): Promise<void>;
getKey(chainId: string): Promise<{ name: string; bech32Address: string }>;
experimentalSuggestChain(chainInfo: unknown): Promise<void>;
getOfflineSigner(chainId: string): unknown;
};
}
}
export type WalletState = {
connected: boolean;
address: string;
balance: string;
name: string;
};
export async function connectKeplr(): Promise<WalletState> {
if (!window.keplr) throw new Error("请安装 Keplr 钱包");
await window.keplr.experimentalSuggestChain(MSG_CHAIN_INFO);
await window.keplr.enable(MSG_CHAIN_INFO.chainId);
const key = await window.keplr.getKey(MSG_CHAIN_INFO.chainId);
const offlineSigner = window.keplr.getOfflineSigner(MSG_CHAIN_INFO.chainId) as any;
const client = await SigningCosmWasmClient.connectWithSigner(
MSG_CHAIN_INFO.rpc, offlineSigner,
{ gasPrice: GasPrice.fromString("1000000000attoMSG") }
);
const balance = await client.getBalance(key.bech32Address, "umsg");
return { connected: true, address: key.bech32Address, balance: balance.amount, name: key.name };
}
export async function connectMnemonic(mnemonic: string): Promise<WalletState> {
const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { prefix: MSG_CHAIN_INFO.bech32Prefix });
const [account] = await wallet.getAccounts();
const client = await SigningCosmWasmClient.connectWithSigner(
MSG_CHAIN_INFO.rpc, wallet,
{ gasPrice: GasPrice.fromString("1000000000attoMSG") }
);
const balance = await client.getBalance(account.address, "umsg");
return { connected: true, address: account.address, balance: balance.amount, name: account.address.slice(0, 12) };
}
4.10 src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 2, staleTime: 30000, refetchOnWindowFocus: false } },
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
4.11 src/App.tsx
import React, { useState, useCallback } from "react";
import { Routes, Route } from "react-router-dom";
import Layout from "./components/Layout";
import HomePage from "./pages/HomePage";
import MarketplacePage from "./pages/MarketplacePage";
import AgentDetailPage from "./pages/AgentDetailPage";
import RegisterAgentPage from "./pages/RegisterAgentPage";
import HireFlowPage from "./pages/HireFlowPage";
import MyAgentsPage from "./pages/MyAgentsPage";
import MySessionsPage from "./pages/MySessionsPage";
import DisputePage from "./pages/DisputePage";
import AgentChatPage from "./pages/AgentChatPage";
import AdminPage from "./pages/AdminPage";
export type WalletState = {
connected: boolean;
address: string;
balance: string;
name: string;
};
const defaultWallet: WalletState = { connected: false, address: "", balance: "0", name: "" };
function App() {
const [wallet, setWallet] = useState<WalletState>(defaultWallet);
const handleConnect = useCallback((w: WalletState) => setWallet(w), []);
const handleDisconnect = useCallback(() => setWallet(defaultWallet), []);
return (
<Layout wallet={wallet} onConnect={handleConnect} onDisconnect={handleDisconnect}>
<Routes>
<Route path="/" element={<HomePage wallet={wallet} />} />
<Route path="/marketplace" element={<MarketplacePage />} />
<Route path="/marketplace/:id" element={<AgentDetailPage wallet={wallet} />} />
<Route path="/agents/register" element={<RegisterAgentPage wallet={wallet} />} />
<Route path="/agents/hire/:listingId" element={<HireFlowPage wallet={wallet} />} />
<Route path="/my-agents" element={<MyAgentsPage wallet={wallet} />} />
<Route path="/my-sessions" element={<MySessionsPage wallet={wallet} />} />
<Route path="/disputes" element={<DisputePage wallet={wallet} />} />
<Route path="/chat/:agentId" element={<AgentChatPage wallet={wallet} />} />
<Route path="/admin" element={<AdminPage wallet={wallet} />} />
</Routes>
</Layout>
);
}
export default App;
4.12 src/components/Layout.tsx
import React from "react";
import { Link, useLocation } from "react-router-dom";
import { Store, UserPlus, ListOrder, MessageSquare, ShieldCheck, Settings } from "lucide-react";
import WalletConnect from "./WalletConnect";
import { WalletState } from "../App";
interface Props {
children: React.ReactNode;
wallet: WalletState;
onConnect: (w: WalletState) => void;
onDisconnect: () => void;
}
const navItems = [
{ path: "/marketplace", label: "市场", icon: Store },
{ path: "/agents/register", label: "注册", icon: UserPlus },
{ path: "/my-agents", label: "我的Agent", icon: ListOrder },
{ path: "/my-sessions", label: "会话", icon: MessageSquare },
{ path: "/disputes", label: "争议", icon: ShieldCheck },
{ path: "/admin", label: "管理", icon: Settings },
];
export default function Layout({ children, wallet, onConnect, onDisconnect }: Props) {
const loc = useLocation();
return (
<div className="min-h-screen flex flex-col">
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-8">
<Link to="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">AI</span>
</div>
<span className="font-bold text-lg text-gray-900">Agent Market</span>
</Link>
<nav className="hidden md:flex items-center gap-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = loc.pathname === item.path;
return (
<Link key={item.path} to={item.path}
className={"flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors " + (isActive ? "bg-primary-50 text-primary-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-50")}>
<Icon className="w-4 h-4" />{item.label}
</Link>
);
})}
</nav>
</div>
<WalletConnect wallet={wallet} onConnect={onConnect} onDisconnect={onDisconnect} />
</div>
</div>
</header>
<main className="flex-1 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full">{children}</main>
<footer className="bg-white border-t border-gray-200 py-6 text-center text-sm text-gray-500">
AI Agent Marketplace © {new Date().getFullYear()} | Powered by MSG Chain
</footer>
</div>
);
}
4.13 src/components/WalletConnect.tsx
import React, { useState } from "react";
import { Wallet, LogOut, Copy, Check } from "lucide-react";
import { connectKeplr, WalletState } from "../services/wallet";
interface Props {
wallet: WalletState;
onConnect: (w: WalletState) => void;
onDisconnect: () => void;
}
export default function WalletConnect({ wallet, onConnect, onDisconnect }: Props) {
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState("");
const handleConnect = async () => {
setLoading(true); setError("");
try { const w = await connectKeplr(); onConnect(w); }
catch (err: any) { setError(err.message || "连接失败"); }
finally { setLoading(false); }
};
const handleCopy = async () => {
if (wallet.connected) {
await navigator.clipboard.writeText(wallet.address);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (wallet.connected) {
return (
<div className="flex items-center gap-3">
<button onClick={handleCopy} className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 rounded-lg text-sm text-gray-700 hover:bg-gray-200">
<Wallet className="w-4 h-4 text-primary-600" />
<span className="font-mono">{wallet.address.slice(0, 8)}...{wallet.address.slice(-4)}</span>
{copied ? <Check className="w-3.5 h-3.5 text-green-600" /> : <Copy className="w-3.5 h-3.5 text-gray-400" />}
</button>
<div className="text-xs text-gray-500">{(parseFloat(wallet.balance) / 1e18).toFixed(4)} MSG</div>
<button onClick={onDisconnect} className="p-1.5 text-gray-400 hover:text-red-600 rounded-lg" title="断开">
<LogOut className="w-4 h-4" />
</button>
</div>
);
}
return (
<div className="flex items-center gap-2">
{error && <span className="text-xs text-red-600 max-w-[200px] truncate">{error}</span>}
<button onClick={handleConnect} disabled={loading} className="btn-primary text-sm flex items-center gap-2">
<Wallet className="w-4 h-4" />{loading ? "连接中..." : "连接钱包"}
</button>
</div>
);
}
4.14 src/components/ListingCard.tsx
import React from "react";
import { Link } from "react-router-dom";
import { Clock, DollarSign, MessageSquare } from "lucide-react";
import { MarketplaceListing } from "../types/marketplace";
import StarRating from "./StarRating";
import StatusBadge from "./StatusBadge";
interface Props { listing: MarketplaceListing; }
export default function ListingCard({ listing }: Props) {
const amount = parseFloat(listing.price.amount) / 1e18;
return (
<Link to={"/marketplace/" + listing.listing_id} className="card hover:shadow-md transition-all block group">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
<span className="text-primary-700 font-bold text-lg">{listing.title.charAt(0).toUpperCase()}</span>
</div>
<div>
<h3 className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">{listing.title}</h3>
<p className="text-xs text-gray-500">{listing.agent_id.slice(0, 12)}...</p>
</div>
</div>
<StatusBadge status={listing.status} />
</div>
<p className="text-sm text-gray-600 line-clamp-2 mb-4">{listing.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-500 mb-3">
<div className="flex items-center gap-1">
<DollarSign className="w-4 h-4 text-accent-600" />
<span className="font-semibold text-gray-900">{amount.toFixed(2)} MSG</span>
<span className="text-xs text-gray-400">/{listing.pricing_model === "fixed" ? "次" : listing.pricing_model === "hourly" ? "小时" : "里程碑"}</span>
</div>
{listing.delivery_days && (
<div className="flex items-center gap-1"><Clock className="w-3.5 h-3.5" /><span>{listing.delivery_days}天</span></div>
)}
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<StarRating rating={listing.average_rating} />
<span className="text-xs text-gray-500">({listing.total_reviews})</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-400">
<MessageSquare className="w-3 h-3" /><span>{listing.completed_orders} 订单</span>
</div>
</div>
{listing.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3">
{listing.tags.slice(0, 3).map((t) => <span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>)}
{listing.tags.length > 3 && <span className="px-2 py-0.5 text-gray-400 text-xs">+{listing.tags.length - 3}</span>}
</div>
)}
</Link>
);
}
4.15 src/components/StarRating.tsx
import React from "react";
import { Star } from "lucide-react";
interface Props { rating: number; size?: number; interactive?: boolean; onChange?: (r: number) => void; }
export default function StarRating({ rating, size = 14, interactive, onChange }: Props) {
const [hover, setHover] = React.useState(0);
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<button key={star} type="button" disabled={!interactive}
onMouseEnter={() => interactive && setHover(star)} onMouseLeave={() => interactive && setHover(0)}
onClick={() => interactive && onChange?.(star)}
className={(interactive ? "cursor-pointer hover:scale-110" : "cursor-default") + " transition-transform"}>
<Star size={size} className={star <= (interactive ? hover || rating : rating) ? "fill-yellow-400 text-yellow-400" : "text-gray-300"} />
</button>
))}
</div>
);
}
4.16 src/components/StatusBadge.tsx
import React from "react";
const colors: Record<string, string> = {
active: "bg-green-100 text-green-800", paused: "bg-yellow-100 text-yellow-800",
removed: "bg-red-100 text-red-800", pending: "bg-yellow-100 text-yellow-800",
completed: "bg-green-100 text-green-800", delivered: "bg-blue-100 text-blue-800",
cancelled: "bg-gray-100 text-gray-800", disputed: "bg-red-100 text-red-800",
online: "bg-green-100 text-green-800", offline: "bg-gray-100 text-gray-500",
};
const labels: Record<string, string> = {
active: "活跃", paused: "暂停", removed: "已下架", pending: "待处理",
completed: "已完成", delivered: "已交付", cancelled: "已取消", disputed: "争议中",
online: "在线", offline: "离线",
};
interface Props { status: string; }
export default function StatusBadge({ status }: Props) {
return (
<span className={"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " + (colors[status] || "bg-gray-100 text-gray-700")}>
{labels[status] || status}
</span>
);
}
4.17 src/components/LoadingState.tsx
import React from "react";
import { Loader2 } from "lucide-react";
export default function LoadingState({ message = "加载中..." }: { message?: string }) {
return (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-primary-600 animate-spin mb-3" />
<p className="text-gray-500 text-sm">{message}</p>
</div>
);
}
4.18 src/components/EmptyState.tsx
import React from "react";
import { Inbox } from "lucide-react";
interface Props { title: string; description?: string; action?: React.ReactNode; }
export default function EmptyState({ title, description, action }: Props) {
return (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<Inbox className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-1">{title}</h3>
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
{action}
</div>
);
}
4.19 src/pages/HomePage.tsx
import React from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Bot, DollarSign, Star, Users } from "lucide-react";
import { getMarketplaceStats, getListings } from "../services/api";
import ListingCard from "../components/ListingCard";
import LoadingState from "../components/LoadingState";
import { WalletState } from "../App";
interface Props { wallet: WalletState; }
export default function HomePage({ wallet }: Props) {
const { data: stats } = useQuery({ queryKey: ["stats"], queryFn: getMarketplaceStats });
const { data: listings, isLoading } = useQuery({ queryKey: ["featured"], queryFn: () => getListings({ limit: "6" }) });
const items = [
{ label: "活跃列表", value: stats?.active_listings || 0, icon: Bot, color: "text-blue-600 bg-blue-50" },
{ label: "已完成订单", value: stats?.completed_orders || 0, icon: Star, color: "text-green-600 bg-green-50" },
{ label: "总评价", value: stats?.total_reviews || 0, icon: Users, color: "text-purple-600 bg-purple-50" },
{ label: "交易量", value: stats?.total_volume ? (parseFloat(stats.total_volume.amount) / 1e18).toFixed(1) + " MSG" : "0 MSG", icon: DollarSign, color: "text-yellow-600 bg-yellow-50" },
];
return (
<div>
<section className="text-center py-16">
<h1 className="text-4xl font-bold text-gray-900 mb-4">AI Agent 市场</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto mb-8">
基于 MSG Chain 的去中心化 AI 代理服务交易平台。发现、雇佣和评价 AI Agent。
</p>
<div className="flex items-center justify-center gap-4">
<Link to="/marketplace" className="btn-primary text-lg px-8 py-3">浏览市场 <ArrowRight className="w-5 h-5 inline ml-1" /></Link>
<Link to="/agents/register" className="btn-secondary text-lg px-8 py-3">注册 Agent</Link>
</div>
{!wallet.connected && <p className="mt-4 text-sm text-yellow-600 bg-yellow-50 px-4 py-2 rounded-lg inline-block">连接 Keplr 钱包以开始使用</p>}
</section>
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-12">
{items.map((item) => {
const Icon = item.icon;
return (
<div key={item.label} className="card flex items-center gap-4">
<div className={"w-12 h-12 rounded-lg flex items-center justify-center " + item.color}>
<Icon className="w-6 h-6" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900">{item.value}</p>
<p className="text-sm text-gray-500">{item.label}</p>
</div>
</div>
);
})}
</section>
<section>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">精选 Agent</h2>
<Link to="/marketplace" className="text-primary-600 text-sm font-medium flex items-center gap-1">查看全部 <ArrowRight className="w-4 h-4" /></Link>
</div>
{isLoading ? <LoadingState /> : listings?.length ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{listings.map((l) => <ListingCard key={l.listing_id} listing={l} />)}
</div>
) : <div className="text-center py-16 text-gray-500">暂无活跃列表</div>}
</section>
</div>
);
}
4.20 src/pages/MarketplacePage.tsx
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Search, SlidersHorizontal, X } from "lucide-react";
import { getListings, searchListings } from "../services/api";
import ListingCard from "../components/ListingCard";
import LoadingState from "../components/LoadingState";
import EmptyState from "../components/EmptyState";
const categories = ["数据分析", "内容生成", "客服", "编程", "设计", "翻译", "金融", "医疗", "教育"];
const pricingModels = ["fixed", "hourly", "milestone"];
export default function MarketplacePage() {
const [query, setQuery] = useState("");
const [cat, setCat] = useState("");
const [pm, setPm] = useState("");
const [showFilters, setShowFilters] = useState(false);
const [debounced, setDebounced] = useState("");
React.useEffect(() => { const t = setTimeout(() => setDebounced(query), 300); return () => clearTimeout(t); }, [query]);
const { data: listings, isLoading } = useQuery({
queryKey: ["listings", debounced, cat, pm],
queryFn: () => debounced ? searchListings(debounced) : getListings({ ...(cat && { categories: cat }), ...(pm && { pricing_model: pm }) }),
});
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Agent 市场</h1>
<button onClick={() => setShowFilters(!showFilters)} className="btn-secondary text-sm flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4" /> 筛选
</button>
</div>
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input type="text" placeholder="搜索 Agent..." value={query} onChange={(e) => setQuery(e.target.value)} className="input-field pl-10 pr-10" />
{query && <button onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"><X className="w-4 h-4" /></button>}
</div>
{showFilters && (
<div className="card mb-6 p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">类别</label>
<div className="flex flex-wrap gap-2">
<button onClick={() => setCat("")} className={"px-3 py-1 rounded-full text-xs font-medium " + (!cat ? "bg-primary-100 text-primary-700" : "bg-gray-100 text-gray-600")}>全部</button>
{categories.map((c) => <button key={c} onClick={() => setCat(cat === c ? "" : c)} className={"px-3 py-1 rounded-full text-xs font-medium " + (cat === c ? "bg-primary-100 text-primary-700" : "bg-gray-100 text-gray-600")}>{c}</button>)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">定价</label>
<div className="flex gap-2">
<button onClick={() => setPm("")} className={"px-3 py-1 rounded-full text-xs font-medium " + (!pm ? "bg-primary-100 text-primary-700" : "bg-gray-100 text-gray-600")}>全部</button>
{pricingModels.map((m) => <button key={m} onClick={() => setPm(pm === m ? "" : m)} className={"px-3 py-1 rounded-full text-xs font-medium " + (pm === m ? "bg-primary-100 text-primary-700" : "bg-gray-100 text-gray-600")}>{m === "fixed" ? "固定价格" : m === "hourly" ? "按小时" : "里程碑"}</button>)}
</div>
</div>
</div>
</div>
)}
{isLoading ? <LoadingState /> : listings?.length ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{listings.map((l) => <ListingCard key={l.listing_id} listing={l} />)}
</div>
) : <EmptyState title="没有找到 Agent" description={debounced ? "没有匹配结果" : "市场暂无列表"} />}
</div>
);
}
4.21 src/pages/AgentDetailPage.tsx
import React from "react";
import { useParams, Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, DollarSign, Clock, Calendar, Bot } from "lucide-react";
import { getListing, getListingReviews } from "../services/api";
import StarRating from "../components/StarRating";
import StatusBadge from "../components/StatusBadge";
import LoadingState from "../components/LoadingState";
import { WalletState } from "../App";
interface Props { wallet: WalletState; }
export default function AgentDetailPage({ wallet }: Props) {
const { id } = useParams<{ id: string }>();
const { data: listing, isLoading } = useQuery({ queryKey: ["listing", id], queryFn: () => getListing(id!), enabled: !!id });
const { data: reviews } = useQuery({ queryKey: ["reviews", id], queryFn: () => getListingReviews(id!), enabled: !!id });
if (isLoading) return <LoadingState />;
if (!listing) return <div className="text-center py-20 text-gray-500">列表不存在</div>;
const amount = parseFloat(listing.price.amount) / 1e18;
return (
<div>
<Link to="/marketplace" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-primary-600 mb-6"><ArrowLeft className="w-4 h-4" /> 返回市场</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<div className="card">
<div className="flex items-start justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-gray-900">{listing.title}</h1>
<StatusBadge status={listing.status} />
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1"><Bot className="w-4 h-4" /> {listing.agent_id.slice(0, 16)}...</span>
<span className="flex items-center gap-1"><Calendar className="w-4 h-4" /> {new Date(listing.created_at * 1000).toLocaleDateString("zh-CN")}</span>
</div>
</div>
</div>
<h3 className="text-lg font-semibold mb-2">描述</h3>
<p className="text-gray-600 whitespace-pre-wrap mb-6">{listing.description}</p>
{listing.terms && (
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<h4 className="text-sm font-semibold mb-2">服务条款</h4>
<p className="text-sm text-gray-600">{listing.terms}</p>
</div>
)}
<div className="flex flex-wrap gap-2 mb-6">
{listing.categories.map((c) => <span key={c} className="px-3 py-1 bg-primary-50 text-primary-700 rounded-full text-xs font-medium">{c}</span>)}
</div>
</div>
<div className="card mt-6">
<h2 className="text-lg font-bold mb-4">评价 ({listing.total_reviews})</h2>
<div className="flex items-center gap-2 mb-6">
<StarRating rating={listing.average_rating} size={20} />
<span className="text-2xl font-bold">{listing.average_rating}</span>
<span className="text-sm text-gray-500">/ 5</span>
</div>
{reviews?.length ? reviews.map((r) => (
<div key={r.review_id} className="p-4 bg-gray-50 rounded-lg mb-3">
<div className="flex items-center gap-2 mb-1">
<StarRating rating={r.rating} />
<span className="text-sm font-medium">{r.reviewer.slice(0, 10)}...</span>
<span className="text-xs text-gray-400">{new Date(r.created_at * 1000).toLocaleDateString("zh-CN")}</span>
</div>
{r.review_text && <p className="text-sm text-gray-600">{r.review_text}</p>}
{r.reply && <div className="mt-2 pl-3 border-l-2 border-primary-300"><p className="text-xs text-gray-500">回复: {r.reply}</p></div>}
</div>
)) : <p className="text-sm text-gray-500">暂无评价</p>}
</div>
</div>
<div>
<div className="card sticky top-24">
<div className="text-center mb-6">
<p className="text-3xl font-bold text-primary-600">{amount.toFixed(2)} <span className="text-lg">MSG</span></p>
<p className="text-sm text-gray-500">/{listing.pricing_model === "fixed" ? "固定价格" : listing.pricing_model === "hourly" ? "小时" : "里程碑"}</p>
</div>
{listing.delivery_days && (
<div className="flex items-center justify-center gap-2 text-sm text-gray-600 mb-6">
<Clock className="w-4 h-4" /> 预计 {listing.delivery_days} 天交付
</div>
)}
<div className="space-y-2 mb-6">
<div className="flex justify-between text-sm"><span className="text-gray-500">订单数</span><span>{listing.total_orders}</span></div>
<div className="flex justify-between text-sm"><span className="text-gray-500">完成率</span><span>{listing.total_orders ? Math.round(listing.completed_orders / listing.total_orders * 100) : 0}%</span></div>
</div>
{wallet.connected ? (
<Link to={"/agents/hire/" + listing.listing_id} className="btn-primary w-full text-center block">雇佣此 Agent</Link>
) : <p className="text-sm text-center text-gray-500">请连接钱包后雇佣</p>}
</div>
</div>
</div>
</div>
);
}
4.22 src/pages/RegisterAgentPage.tsx
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { postTx } from "../services/api";
import { WalletState } from "../App";
interface Props { wallet: WalletState; }
export default function RegisterAgentPage({ wallet }: Props) {
const nav = useNavigate();
const [form, setForm] = useState({ agent_id: "", title: "", description: "", price: "", denom: "umsg", pricing_model: "fixed", categories: "", tags: "" });
const mutation = useMutation({
mutationFn: (data: Record<string, unknown>) => postTx("/tx/create-listing", data),
onSuccess: () => nav("/marketplace"),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!wallet.connected) return;
mutation.mutate({
agent_id: form.agent_id, title: form.title, description: form.description,
price: (parseFloat(form.price) * 1e18).toString(), denom: form.denom,
pricing_model: form.pricing_model, categories: form.categories.split(",").map((s) => s.trim()).filter(Boolean),
tags: form.tags.split(",").map((s) => s.trim()).filter(Boolean),
});
};
if (!wallet.connected) return <div className="text-center py-20 text-gray-500">请先连接钱包</div>;
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6">注册 Agent 列表</h1>
<form onSubmit={handleSubmit} className="card space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Agent ID</label>
<input type="text" value={form.agent_id} onChange={(e) => setForm({ ...form, agent_id: e.target.value })} className="input-field" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">标题</label>
<input type="text" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} className="input-field" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">描述</label>
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="input-field" rows={4} required />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">价格 (MSG)</label>
<input type="number" step="0.01" value={form.price} onChange={(e) => setForm({ ...form, price: e.target.value })} className="input-field" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">定价模式</label>
<select value={form.pricing_model} onChange={(e) => setForm({ ...form, pricing_model: e.target.value })} className="input-field">
<option value="fixed">固定价格</option>
<option value="hourly">按小时</option>
<option value="milestone">里程碑</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">类别 (逗号分隔)</label>
<input type="text" value={form.categories} onChange={(e) => setForm({ ...form, categories: e.target.value })} className="input-field" placeholder="数据分析, 内容生成" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">标签 (逗号分隔)</label>
<input type="text" value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} className="input-field" placeholder="AI, NLP, Python" />
</div>
<button type="submit" disabled={mutation.isPending} className="btn-primary w-full">
{mutation.isPending ? "提交中..." : "创建列表"}
</button>
{mutation.isError && <p className="text-sm text-red-600">{(mutation.error as Error).message}</p>}
</form>
</div>
);
}
4.23 src/pages/HireFlowPage.tsx
import React, { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation } from "@tanstack/react-query";
import { postTx, getListing } from "../services/api";
import { WalletState } from "../App";
interface Props { wallet: WalletState; }
export default function HireFlowPage({ wallet }: Props) {
const { listingId } = useParams<{ listingId: string }>();
const nav = useNavigate();
const { data: listing } = useQuery({ queryKey: ["listing", listingId], queryFn: () => getListing(listingId!), enabled: !!listingId });
const [milestones, setMilestones] = useState([{ title: "", description: "", amount: "", due_days: 7 }]);
const mutation = useMutation({
mutationFn: (data: Record<string, unknown>) => postTx("/tx/create-order", data),
onSuccess: () => nav("/my-sessions"),
});
const addMilestone = () => setMilestones([...milestones, { title: "", description: "", amount: "", due_days: 7 }]);
const updateMilestone = (i: number, field: string, value: string | number) => {
const m = [...milestones]; (m[i] as any)[field] = value; setMilestones(m);
};
const removeMilestone = (i: number) => setMilestones(milestones.filter((_, idx) => idx !== i));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({
listing_id: listingId, client: wallet.address,
milestones: milestones.map((m) => ({ title: m.title, description: m.description, amount: (parseFloat(m.amount) * 1e18).toString(), due_days: m.due_days })),
expiry: Math.floor(Date.now() / 1000) + 30 * 86400,
});
};
if (!wallet.connected) return <div className="text-center py-20 text-gray-500">请先连接钱包</div>;
if (!listing) return null;
return (
<div className="max-w-3xl mx-auto">
<h1 className="text-2xl font-bold mb-2">雇佣 Agent</h1>
<p className="text-gray-500 mb-6">{listing.title}</p>
<form onSubmit={handleSubmit} className="space-y-6">
{milestones.map((m, i) => (
<div key={i} className="card">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">里程碑 {i + 1}</h3>
{milestones.length > 1 && <button type="button" onClick={() => removeMilestone(i)} className="text-sm text-red-600">删除</button>}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm text-gray-700 mb-1">标题</label>
<input type="text" value={m.title} onChange={(e) => updateMilestone(i, "title", e.target.value)} className="input-field" required />
</div>
<div className="col-span-2">
<label className="block text-sm text-gray-700 mb-1">描述</label>
<input type="text" value={m.description} onChange={(e) => updateMilestone(i, "description", e.target.value)} className="input-field" />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">金额 (MSG)</label>
<input type="number" step="0.01" value={m.amount} onChange={(e) => updateMilestone(i, "amount", e.target.value)} className="input-field" required />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">交付天数</label>
<input type="number" value={m.due_days} onChange={(e) => updateMilestone(i, "due_days", parseInt(e.target.value))} className="input-field" required />
</div>
</div>
</div>
))}
<button type="button" onClick={addMilestone} className="btn-secondary w-full">+ 添加里程碑</button>
<button type="submit" disabled={mutation.isPending} className="btn-primary w-full">
{mutation.isPending ? "提交中..." : "创建订单"}
</button>
{mutation.isError && <p className="text-sm text-red-600">{(mutation.error as Error).message}</p>}
</form>
</div>
);
}
4.24 src/pages/MyAgentsPage.tsx
import React from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { getListingsByAgent } from "../services/api";
import ListingCard from "../components/ListingCard";
import LoadingState from "../components/LoadingState";
import EmptyState from "../components/EmptyState";
import { WalletState } from "../App";
interface Props { wallet: WalletState; }
export default function MyAgentsPage({ wallet }: Props) {
const agentId = wallet.connected ? wallet.address : "";
const { data: listings, isLoading } = useQuery({
queryKey: ["my-agents", agentId],
queryFn: () => getListingsByAgent(agentId),
enabled: !!agentId,
});
if (!wallet.connected) return <div className="text-center py-20 text-gray-500">请先连接钱包</div>;
if (isLoading) return <LoadingState />;
return (
<div>
<h1 className="text-2xl font-bold mb-6">我的 Agent 列表</h1>
{listings?.length ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{listings.map((l) => <ListingCard key={l.listing_id} listing={l} />)}
</div>
) : <EmptyState title="暂无 Agent 列表" description="注册一个 Agent 开始提供 AI 服务" />}
</div>
);
}
4.25 src/pages/MySessionsPage.tsx
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { getOrdersByClient } from "../services/api";
import StatusBadge from "../components/StatusBadge";
import LoadingState from "../components/LoadingState";
import EmptyState from "../components/EmptyState";
import { WalletState } from "../App";
interface Props { wallet: WalletState; }
export default function MySessionsPage({ wallet }: Props) {
const { data: orders, isLoading } = useQuery({
queryKey: ["my-orders", wallet.address],
queryFn: () => getOrdersByClient(wallet.address),
enabled: wallet.connected,
});
if (!wallet.connected) return <div className="text-center py-20 text-gray-500">请先连接钱包</div>;
if (isLoading) return <LoadingState />;
return (
<div>
<h1 className="text-2xl font-bold mb-6">我的订单</h1>
{orders?.length ? (
<div className="space-y-4">
{orders.map((order) => (
<div key={order.order_id} className="card flex items-center justify-between">
<div>
<p className="font-semibold">订单 #{order.order_id.slice(-8)}</p>
<p className="text-sm text-gray-500">Agent: {order.agent_id.slice(0, 12)}... | {(parseFloat(order.total_amount) / 1e18).toFixed(2)} MSG</p>
<p className="text-xs text-gray-400">{new Date(order.created_at * 1000).toLocaleDateString("zh-CN")}</p>
</div>
<StatusBadge status={order.status} />
</div>
))}
</div>
) : <EmptyState title="暂无订单" description="雇佣一个 Agent 来开始合作" />}
</div>
);
}
4.26 src/pages/DisputePage.tsx
import React, { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { getOrdersByClient } from "../services/api";
import { WalletState } from "../App";
interface Props { wallet: WalletState; }
export default function DisputePage({ wallet }: Props) {
const { data: orders } = useQuery({
queryKey: ["dispute-orders", wallet.address],
queryFn: () => getOrdersByClient(wallet.address),
enabled: wallet.connected,
});
const disputed = orders?.filter((o) => o.status === "disputed");
if (!wallet.connected) return <div className="text-center py-20 text-gray-500">请先连接钱包</div>;
return (
<div>
<h1 className="text-2xl font-bold mb-6">争议处理</h1>
{disputed?.length ? (
<div className="space-y-4">
{disputed.map((order) => (
<div key={order.order_id} className="card">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">订单 #{order.order_id.slice(-8)}</h3>
<span className="px-2 py-0.5 bg-red-100 text-red-800 rounded-full text-xs font-medium">争议中</span>
</div>
<p className="text-sm text-gray-500 mb-4">金额: {(parseFloat(order.total_amount) / 1e18).toFixed(2)} MSG | Agent: {order.agent_id.slice(0, 12)}...</p>
<p className="text-sm text-gray-600">请通过链上治理流程解决争议。联系管理员提供证据。</p>
</div>
))}
</div>
) : (
<div className="card text-center py-12">
<p className="text-gray-500">暂无争议订单</p>
</div>
)}
</div>
);
}
4.27 src/pages/AgentChatPage.tsx
import React, { useState, useRef, useEffect } from "react";
import { useParams } from "react-router-dom";
import { Send, Bot, User } from "lucide-react";
import { queryAgent } from "../services/api";
import { WalletState } from "../App";
interface Message { id: string; role: "user" | "agent"; content: string; timestamp: number; }
interface Props { wallet: WalletState; }
export default function AgentChatPage({ wallet }: Props) {
const { agentId } = useParams<{ agentId: string }>();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]);
const handleSend = async () => {
if (!input.trim() || !agentId) return;
const userMsg: Message = { id: Date.now().toString(), role: "user", content: input, timestamp: Date.now() };
setMessages((prev) => [...prev, userMsg]);
setInput("");
setLoading(true);
try {
const res = await queryAgent({ query: input, agent_id: agentId });
const agentMsg: Message = { id: (Date.now() + 1).toString(), role: "agent", content: res.response, timestamp: Date.now() };
setMessages((prev) => [...prev, agentMsg]);
} catch (err: any) {
const errMsg: Message = { id: (Date.now() + 1).toString(), role: "agent", content: "错误: " + (err.message || "连接失败"), timestamp: Date.now() };
setMessages((prev) => [...prev, errMsg]);
} finally { setLoading(false); }
};
return (
<div className="max-w-3xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Agent 对话 - {agentId?.slice(0, 12)}...</h1>
<div className="card h-[600px] flex flex-col">
<div className="flex-1 overflow-y-auto mb-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-400 py-20">开始与 Agent 对话</div>
)}
{messages.map((m) => (
<div key={m.id} className={"flex " + (m.role === "user" ? "justify-end" : "justify-start")}>
<div className={"max-w-[80%] p-3 rounded-lg " + (m.role === "user" ? "bg-primary-600 text-white" : "bg-gray-100 text-gray-800")}>
<div className="flex items-center gap-1 mb-1">
{m.role === "user" ? <User className="w-3 h-3" /> : <Bot className="w-3 h-3" />}
<span className="text-xs opacity-70">{m.role === "user" ? "我" : "Agent"}</span>
</div>
<p className="text-sm whitespace-pre-wrap">{m.content}</p>
</div>
</div>
))}
{loading && <div className="text-center text-gray-400 text-sm">Agent 正在思考...</div>}
<div ref={bottomRef} />
</div>
<div className="flex items-center gap-2 border-t pt-4">
<input type="text" value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSend()} placeholder="输入消息..." className="input-field flex-1" disabled={loading} />
<button onClick={handleSend} disabled={loading || !input.trim()} className="btn-primary p-2"><Send className="w-5 h-5" /></button>
</div>
</div>
</div>
);
}
4.28 src/pages/AdminPage.tsx
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getMarketplaceStats, getListings } from "../services/api";
import StatusBadge from "../components/StatusBadge";
import LoadingState from "../components/LoadingState";
import { WalletState } from "../App";
interface Props { wallet: WalletState; }
export default function AdminPage({ wallet }: Props) {
const [feePercent, setFeePercent] = useState("250");
const { data: stats } = useQuery({ queryKey: ["admin-stats"], queryFn: getMarketplaceStats });
const { data: listings, isLoading } = useQuery({ queryKey: ["admin-listings"], queryFn: () => getListings() });
if (!wallet.connected) return <div className="text-center py-20 text-gray-500">请先连接钱包</div>;
return (
<div>
<h1 className="text-2xl font-bold mb-6">管理面板</h1>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="card text-center"><p className="text-2xl font-bold">{stats?.total_listings || 0}</p><p className="text-sm text-gray-500">总列表</p></div>
<div className="card text-center"><p className="text-2xl font-bold">{stats?.active_listings || 0}</p><p className="text-sm text-gray-500">活跃</p></div>
<div className="card text-center"><p className="text-2xl font-bold">{stats?.total_orders || 0}</p><p className="text-sm text-gray-500">订单</p></div>
<div className="card text-center"><p className="text-2xl font-bold">{stats?.total_agents || 0}</p><p className="text-sm text-gray-500">Agent</p></div>
</div>
<div className="card mb-8">
<h2 className="text-lg font-bold mb-4">平台费率</h2>
<div className="flex items-center gap-4">
<input type="number" value={feePercent} onChange={(e) => setFeePercent(e.target.value)} className="input-field w-32" />
<span className="text-sm text-gray-500">基点 (250 = 2.5%)</span>
<button className="btn-primary">更新</button>
</div>
</div>
<div className="card">
<h2 className="text-lg font-bold mb-4">所有列表</h2>
{isLoading ? <LoadingState /> : listings?.length ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left border-b"><th className="pb-2">ID</th><th className="pb-2">标题</th><th className="pb-2">价格</th><th className="pb-2">状态</th><th className="pb-2">评价</th></tr></thead>
<tbody>
{listings.map((l) => (
<tr key={l.listing_id} className="border-b border-gray-50 hover:bg-gray-50">
<td className="py-2 font-mono text-xs">{l.listing_id.slice(-8)}</td>
<td className="py-2">{l.title}</td>
<td className="py-2">{(parseFloat(l.price.amount) / 1e18).toFixed(2)} MSG</td>
<td className="py-2"><StatusBadge status={l.status} /></td>
<td className="py-2">{l.average_rating}/5 ({l.total_reviews})</td>
</tr>
))}
</tbody>
</table>
</div>
) : <p className="text-gray-500">暂无列表</p>}
</div>
</div>
);
}
5. Part 4: 部署脚本与 DevOps
5.1 docker-compose.yml (根目录)
version: "3.8"
services:
backend:
build: ./backend
ports:
- "3000:3000"
environment:
- RPC_ENDPOINT=http://msg-chain-node:26657
- MARKETPLACE_CONTRACT=${MARKETPLACE_CONTRACT}
- REGISTRY_CONTRACT=${REGISTRY_CONTRACT}
- PAYMENT_CONTRACT=${PAYMENT_CONTRACT}
- DID_CONTRACT=${DID_CONTRACT}
- CONSTITUTION_CONTRACT=${CONSTITUTION_CONTRACT}
- ADMIN_MNEMONIC=${ADMIN_MNEMONIC}
- ADMIN_ADDRESS=${ADMIN_ADDRESS}
- CORS_ORIGIN=http://localhost:5173
- NODE_ENV=development
depends_on:
- msg-chain-node
- agent-runtime
networks:
- marketplace-net
frontend:
build: ./frontend
ports:
- "5173:80"
depends_on:
- backend
networks:
- marketplace-net
msg-chain-node:
image: ghcr.io/msgchain/msg-chain:latest
ports:
- "26657:26657"
- "1317:1317"
volumes:
- msg-chain-data:/root/.msg-chain
command: >
msg-chaind start --rpc.laddr tcp://0.0.0.0:26657
networks:
- marketplace-net
agent-runtime:
image: ghcr.io/msgchain/agent-runtime:latest
ports:
- "8080:8080"
environment:
- AGENT_RUNTIME_PORT=8080
networks:
- marketplace-net
redis:
image: redis:7-alpine
ports:
- "6379:6379"
networks:
- marketplace-net
volumes:
msg-chain-data:
networks:
marketplace-net:
driver: bridge
5.2 合约部署脚本 (deploy-contracts.sh)
#!/bin/bash
set -e
CHAIN_ID="msg-chain-1"
NODE="http://localhost:26657"
KEYRING="test"
FROM="admin"
echo "=== 部署 AI Agent Marketplace 合约 ==="
# 1. 编译合约
echo "[1/5] 编译合约..."
cd contracts/marketplace_v1
cargo wasm
cp target/wasm32-unknown-unknown/release/marketplace_v1.wasm ../../artifacts/
cd ../..
# 2. 存储合约
echo "[2/5] 存储合约..."
STORAGE_RES=$(msg-chaind tx wasm store artifacts/marketplace_v1.wasm \
--from $FROM --chain-id $CHAIN_ID --node $NODE \
--gas auto --gas-prices 1000000000attoMSG --gas-adjustment 1.3 \
--keyring-backend $KEYRING -y --output json)
CODE_ID=$(echo $STORAGE_RES | jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "Code ID: $CODE_ID"
# 3. 获取其他合约地址
REGISTRY_CONTRACT=$(msg-chaind query wasm list-contract-by-code 1 --node $NODE --output json | jq -r '.contracts[0]')
PAYMENT_CONTRACT=$(msg-chaind query wasm list-contract-by-code 2 --node $NODE --output json | jq -r '.contracts[0]')
DID_CONTRACT=$(msg-chaind query wasm list-contract-by-code 3 --node $NODE --output json | jq -r '.contracts[0]')
CONST_CONTRACT=$(msg-chaind query wasm list-contract-by-code 4 --node $NODE --output json | jq -r '.contracts[0]')
ADMIN_ADDRESS=$(msg-chaind keys show $FROM --keyring-backend $KEYRING -a)
# 4. 实例化合约
echo "[3/5] 实例化 Marketplace 合约..."
INIT_MSG='{
"registry_contract": "'$REGISTRY_CONTRACT'",
"payment_contract": "'$PAYMENT_CONTRACT'",
"constitution_contract": "'$CONST_CONTRACT'",
"did_registry_contract": "'$DID_CONTRACT'",
"admin": "'$ADMIN_ADDRESS'",
"platform_fee_percent": 250
}'
msg-chaind tx wasm instantiate $CODE_ID "$INIT_MSG" \
--from $FROM --chain-id $CHAIN_ID --node $NODE \
--label "marketplace-v1" --admin $ADMIN_ADDRESS \
--gas auto --gas-prices 1000000000attoMSG --gas-adjustment 1.3 \
--keyring-backend $KEYRING -y
# 5. 获取合约地址
echo "[4/5] 获取 Marketplace 地址..."
sleep 5
MARKETPLACE_CONTRACT=$(msg-chaind query wasm list-contract-by-code $CODE_ID --node $NODE --output json | jq -r '.contracts[0]')
echo "[5/5] 导出环境变量..."
echo "export MARKETPLACE_CONTRACT=$MARKETPLACE_CONTRACT" > .env.contracts
echo "export REGISTRY_CONTRACT=$REGISTRY_CONTRACT" >> .env.contracts
echo "export PAYMENT_CONTRACT=$PAYMENT_CONTRACT" >> .env.contracts
echo "export DID_CONTRACT=$DID_CONTRACT" >> .env.contracts
echo "export CONSTITUTION_CONTRACT=$CONST_CONTRACT" >> .env.contracts
echo "export ADMIN_ADDRESS=$ADMIN_ADDRESS" >> .env.contracts
echo ""
echo "=== 部署完成 ==="
echo "Marketplace: $MARKETPLACE_CONTRACT"
echo "Registry: $REGISTRY_CONTRACT"
echo "Payment: $PAYMENT_CONTRACT"
echo "DID: $DID_CONTRACT"
echo "Constitution: $CONST_CONTRACT"
echo "Admin: $ADMIN_ADDRESS"
5.3 前端 Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json tsconfig.json vite.config.ts tailwind.config.js postcss.config.js ./
RUN npm ci
COPY public/ public/
COPY src/ src/
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;"]
5.4 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://backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws/ {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
5.5 Schema 生成命令
# 安装 cosmwasm-schema
cargo install cosmwasm-schema
# 生成 JSON Schema
cd contracts/marketplace_v1
cargo schema
5.6 前端构建与部署
# 安装依赖
cd frontend
npm install
# 开发模式
npm run dev
# 生产构建
npm run build
# 预览
npm run preview
5.7 后端启动
# 安装依赖
cd backend
npm install
# 复制环境变量
cp .env.example .env
# 编辑 .env 填入合约地址
# 开发模式
npm run dev
# 生产构建
npm run build
npm start
6. 附录: 完整交互流程
6.1 用户流程
1. 用户连接 Keplr 钱包
2. 浏览 Agent 市场 (MarketplacePage)
3. 搜索/筛选 Agent
4. 查看 Agent 详情和评价 (AgentDetailPage)
5. 点击"雇佣此 Agent"
6. 配置里程碑和金额 (HireFlowPage)
7. 提交订单 -> ExecuteMsg::CreateOrder
8. Agent 所有者接受订单 -> ExecuteMsg::AcceptOrder
9. Agent 执行工作
10. Agent 标记已交付 -> ExecuteMsg::MarkDelivered
11. 用户确认完成 -> ExecuteMsg::CompleteOrder
12. 用户提交评价 -> ExecuteMsg::SubmitReview
13. 争议处理 -> DisputePage (链上治理)
6.2 数据流
Frontend (React) --REST/WS--> Backend (Express) --CosmWasm--> MSG Chain
|
+--HTTP--> Agent Runtime (AI Agent APIs)
6.3 示例: 创建列表交易
// POST /api/tx/create-listing
{
"agent_id": "agent_001",
"title": "AI 数据分析助手",
"description": "专业的数据分析和可视化服务...",
"price": "50000000000000000000",
"denom": "umsg",
"pricing_model": "fixed",
"categories": ["数据分析", "AI"],
"tags": ["python", "data-viz", "machine-learning"]
}
// Response: { "success": true, "data": { "tx_hash": "ABCD1234..." } }
本文档由 AI 自动生成,包含完整的 CosmWasm 智能合约、Node.js 后端、React 前端及部署配置。
任何 AI Agent 均可使用此文档 100% 复现整个 dApp。所有代码均为生产就绪状态。
