29 个版本 (6 个稳定版)
新版本 1.2.1 | 2024 年 8 月 22 日 |
---|---|
1.1.0 | 2024 年 7 月 12 日 |
1.0.0 | 2024 年 3 月 27 日 |
0.10.1 | 2024 年 3 月 27 日 |
0.1.2 | 2022 年 5 月 31 日 |
#319 在 魔法豆
1,113 每月下载量
用于 vectis-wallet
97KB
1.5K SLoC
Sylvia 框架
Sylvia 是古老的名称,意为森林之灵。
Sylvia 是罗马森林女神。
Sylvia 还是一个框架,旨在为您提供关注抽象和可扩展的解决方案,用于构建您的 CosmWasm 智能合约。找到您进入 Cosmos 生态系统的路径。我们为您提供工具集,因此您不必关注合约的原始结构,而可以在正确的 Rust 语法中创建它,然后让 cargo 确保它是有效的。
在此书中了解更多关于 sylvia
的信息
Sylvia 合约模板
Sylvia 模板通过提供遵循最佳实践并利用 Sylvia 框架强大功能的项目脚手架,简化了 CosmWasm 智能合约的开发。它旨在帮助开发者更多地关注合约的业务逻辑,而不是样板代码。
在此了解更多:Sylvia 模板在 GitHub 上的信息
方法
CosmWasm生态系统核心为智能合约提供基础构建模块 - cosmwasm-std 用于基本的 CW 绑定,cw-storage-plus 用于更简单的状态管理,以及 cw-multi-test 用于测试。Sylvia 框架建立在它们之上,因此创建合约时,你无需考虑消息结构、它们的 API(反)序列化方式或如何处理消息分发。相反,你的合约 API 是一组你将在合约类型上实现的特质。框架会生成诸如入口点结构、消息分发函数或甚至多测试辅助工具等。它允许更好地控制接口,包括在编译时验证它们的完整性。
代码生成
Sylvia 宏在 sv
模块中生成代码。这意味着每个 contract
和 interface
宏调用都必须在单独的模块中进行,以避免生成模块之间的冲突。
合约类型
在 Sylvia 中,我们将合约定义为结构
use cw_storage_plus::Item;
use cosmwasm_schema::cw_serde;
use sylvia::types::QueryCtx;
use sylvia::cw_std::ensure;
/// Our new contract type.
///
struct MyContract<'a> {
pub counter: Item<'a, u64>,
}
/// Response type returned by the
/// query method.
///
#[cw_serde]
pub struct CounterResp {
pub counter: u64,
}
#[entry_points]
#[contract]
#[sv::error(ContractError)]
impl MyContract<'_> {
pub fn new() -> Self {
Self {
counter: Item::new("counter")
}
}
#[sv::msg(instantiate)]
pub fn instantiate(&self, ctx: InstantiateCtx, counter: u64) -> StdResult<Response> {
self.counter.save(ctx.deps.storage, &counter)?;
Ok(Response::new())
}
#[sv::msg(exec)]
pub fn increment(&self, ctx: ExecCtx) -> Result<Response, ContractError> {
let counter = self.counter.load(ctx.deps.storage)?;
ensure!(counter < 10, ContractError::LimitReached);
self.counter.save(ctx.deps.storage, &(counter + 1))?;
Ok(Response::new())
}
#[sv::msg(query)]
pub fn counter(&self, ctx: QueryCtx) -> StdResult<CounterResp> {
self
.counter
.load(ctx.deps.storage)
.map(|counter| CounterResp { counter })
}
}
Sylvia 将生成以下新的结构
pub mod sv {
use super::*;
struct InstantiateMsg {
counter: u64,
}
enum ExecMsg {
Increment {}
}
enum ContractExecMsg {
MyContract(ExecMsg)
}
enum QueryMsg {
Counter {}
}
enum ContractQueryMsg {
MyContract(QueryMsg)
}
// [...]
}
pub mod entry_points {
use super::*;
#[sylvia::cw_std::entry_point]
pub fn instantiate(
deps: sylvia::cw_std::DepsMut,
env: sylvia::cw_std::Env,
info: sylvia::cw_std::MessageInfo,
msg: InstantiateMsg,
) -> Result<sylvia::cw_std::Response, StdError> {
msg.dispatch(&MyContract::new(), (deps, env, info))
.map_err(Into::into)
}
// [...]
}
entry_points
宏生成 instantiate
、execute
、query
和 sudo
入口点。所有这些方法都调用接收到的 msg 上的 dispatch
,并运行为发送的消息变体定义的适当逻辑。
什么是重要的 - InstantiateMsg
(和其他消息)中的字段与函数参数具有相同的名称。
ExecMsg
是你可以用来向合约发送消息的主要消息。而 ContractExecMsg
只是一个额外的抽象层,它将在我们定义合约特质时变得重要。多亏了 entry_point
宏,它已经在生成的入口点中使用了,我们无需手动完成。
你可能注意到 - 如果我们不需要在特定函数中使用 ContractError
,我们仍然可以使用 StdResult
(因此 StdError
)。重要的是,返回的结果类型必须实现 Into<ContractError>
,其中 ContractError
是合约错误类型 - 所有这些都将统一在生成的分发函数中(因此入口点必须以 ContractError
作为其错误变体返回)。
接口
Sylvia 框架的一个基本思想是接口,它允许将消息分组到它们的语义组中。让我们定义一个 Sylvia 接口
pub mod group {
use super::*;
use sylvia::interface;
use sylvia::types::ExecCtx;
use sylvia::cw_std::StdError;
#[cw_serde]
pub struct IsMemberResp {
pub is_member: bool,
}
#[interface]
pub trait Group {
type Error: From<StdError>;
#[sv::msg(exec)]
fn add_member(&self, ctx: ExecCtx, member: String) -> Result<Response, Self::Error>;
#[sv::msg(query)]
fn is_member(&self, ctx: QueryCtx, member: String) -> Result<IsMemberResp, Self::Error>;
}
}
然后我们需要在合约类型上实现这个特质
use sylvia::cw_std::{Empty, Addr};
use cw_storage_plus::{Map, Item};
pub struct MyContract<'a> {
counter: Item<'a, u64>,
// New field added - remember to initialize it in `new`
members: Map<'a, &'a Addr, Empty>,
}
impl group::Group for MyContract<'_> {
type Error = ContractError;
fn add_member(&self, ctx: ExecCtx, member: String) -> Result<Response, ContractError> {
let member = ctx.deps.api.addr_validate(&member)?;
self.members.save(ctx.deps.storage, &member, &Empty {})?;
Ok(Response::new())
}
fn is_member(&self, ctx: QueryCtx, member: String) -> Result<group::IsMemberResp, ContractError> {
let is_member = self.members.has(ctx.deps.storage, &Addr::unchecked(&member));
let resp = group::IsMemberResp {
is_member,
};
Ok(resp)
}
}
#[contract]
#[sv::messages(group as Group)]
impl MyContract<'_> {
// Nothing changed here
}
首先,请注意我定义了接口特质在一个独立的模块中,模块的名称与特质名称相同,但使用的是“snake_case”而不是CamelCase。这里有一个为Group
特质的group
模块,但CrossStaking
特质应该放在它自己的cross_staking
模块中(注意下划线)。这是目前的要求——Sylvia将在该模块中生成所有消息和样板代码,并通过该模块尝试访问它们。如果接口的名称是最后模块路径段的驼峰式版本,则可以省略as InterfaceName
。例如,#[sv::messages(cw1 as Cw1)]
可以简化为#[sv::messages(cw1)]
然后,特质中嵌入了一个Error
类型——它在那里也是必需的,并且这里的特质界限至少为From<StdError>
,因为Sylvia可能会在反序列化/调度实现中生成返回StdError
的代码。特质可以更加严格——这是最小要求。
最后,实现块有一个额外的#[sv::messages(module as Identifier)]
属性。Sylvia需要它来正确生成调度——有一个限制,即每个宏只能访问其局部作用域。特别是——我们不能看到类型实现的所有特质以及它们的实现,来自#[contract]
存储库。
为了解决这个问题,我们将这个#[sv::messages(...)]
属性指向Sylvia,说明接口定义的模块名称,并为这个接口提供一个唯一的名称(它将在生成的代码中用于提供正确的枚举变体)。
宏属性
struct MyMsg;
impl CustomMsg for MyMsg {}
struct MyQuery;
impl CustomQuery for MyMsg {}
#[entry_point]
#[contract]
#[sv::error(ContractError)]
#[sv::messages(interface as Interface)]
#[sv::messages(interface as InterfaceWithCustomType: custom(msg, query))]
#[sv::custom(msg=MyMsg, query=MyQuery)]
#[sv::msg_attr(exec, PartialOrd)]
#[sv::override_entry_point(sudo=crate::entry_points::sudo(crate::SudoMsg))]
impl MyContract {
// ...
#[sv::msg(query)]
#[sv::attr(serde(rename(serialize = "CustomQueryMsg")))]
fn query_msg(&self, _ctx: QueryCtx) -> StdResult<Response> {
// ...
}
}
-
sv::error
被contract
和entry_point
宏共同使用。如果您在合约中使用自定义错误,则这是必需的。如果省略,生成的代码将使用StdError
。 -
sv::messages
是contract
宏的属性。它的目的是通知 Sylvia 关于合同实现的服务接口。如果实现的服务接口没有使用默认的Empty
消息响应来查询和/或执行,则应指示: custom(query)
、: custom(msg)
或: custom(msg, query)
。 -
sv::override_entry_point
- 请参阅覆盖入口点
部分。 -
sv::custom
允许为合同定义 CustomMsg 和 CustomQuery。默认生成的代码将返回Response<Empty>
,并使用Deps<Empty>
和DepsMut<Empty>
。 -
sv::msg_attr
将任何属性转发到消息的类型。 -
sv::attr
将任何属性转发到枚举的变体。
外部crate中的使用
重要的是使用生成代码在外部代码中的可能性。首先,让我们从生成 crate 的文档开始
cargo doc --document-private-items --open
这将生成并打开 crate 的文档,包括所有生成的结构。--document-private-item
是可选的,但它将生成非公开模块的文档,这在某些情况下可能很有用。
在查看文档时,您将看到所有消息都是在它们的结构体/特质模块中生成的。要向合同发送消息,我们可以直接使用它们
use sylvia::cw_std::{WasmMsg, to_json_binary};
fn some_handler(my_contract_addr: String) -> StdResult<Response> {
let msg = my_contract_crate::sv::ExecMsg::Increment {};
let msg = WasmMsg::ExecMsg {
contract_addr: my_contract_addr,
msg: to_json_binary(&msg)?,
funds: vec![],
}
let resp = Response::new()
.add_message(msg);
Ok(resp)
}
我们可以用类似的方式使用特质中的消息
let msg = my_contract_crate::group::QueryMsg::IsMember {
member: addr,
};
let is_member: my_contract_crate::group::IsMemberResp =
deps.querier.query_wasm_smart(my_contract_addr, &msg)?;
重要的是不要混淆生成的 ContractExecMsg/ContractQueryMsg
与 ExecMsg/QueryMsg
- 前者是仅为合同生成,不是为接口,也不用于向合同发送消息 - 它们的目的是仅用于正确的消息分发,不应在入口点之外使用。
查询助手
为了使查询更加用户友好,Sylvia
为用户提供 sylvia::types::BoundQuerier
和 sylvia::types::Remote
助手。后者用于存储某些远程合同的地址。对于合同中的每个查询方法,Sylvia 都会在生成的 sv::Querier
特质中添加一个方法。然后 sv::Querier
为 sylvia::types::BoundQuerier
实现,这样用户就可以调用该方法。
让我们修改前一段中的查询。目前,它将如下所示
let is_member = Remote::<OtherContractType>::new(remote_addr)
.querier(&ctx.deps.querier)
.is_member(addr)?;
您的合同可能需要定期与某些其他合同通信。在这种情况下,您可能希望将其存储为 Contract 中的字段
pub struct MyContract<'a> {
counter: Item<'a, u64>,
members: Map<'a, &'a Addr, Empty>,
remote: Item<'a, Remote<'static, OtherContractType>>,
}
#[sv::msg(exec)]
pub fn evaluate_member(&self, ctx: ExecCtx, ...) -> StdResult<Response> {
let is_member = self
.remote
.load(ctx.deps.storage)?
.querier(&ctx.deps.querier)
.is_member(addr)?;
}
执行器消息构建器
Sylvia 定义了 ExecutorBuilder
类型,可以通过 Remote::executor
访问。它针对合约类型进行泛型化,并通过自动生成的 Executor
特性暴露合约及其所有接口的执行方法。可以使用 Remote
构建 其他合约的执行消息,通过调用 executor
方法。它返回一个消息构建器,该构建器实现了所有 Sylvia 合约的自动生成的 Executor
特性。在 Executor
特性中定义的方法构建一个执行消息,其变体对应于方法名。然后,消息被包装在 WasmMsg
中,并在调用 ExecutorBuilder::build()
方法后返回。
use sylvia::types::Remote;
use other_contract::contract::OtherContract;
use other_contract::contract::sv::Executor;
let some_exec_msg: WasmMsg = Remote::<OtherContract>::new(remote_addr)
.executor()
.some_exec_method()?
.build();
使用不支持的入口点
如果需要 Sylvia 中未实现的入口点,可以使用 #[entry_point]
宏手动实现。以下是如何实现消息回复的示例:
use sylvia::cw_std::{DepsMut, Env, Reply, Response};
#[contract]
#[entry_point]
#[sv::error(ContractError)]
#[sv::messages(group as Group)]
impl MyContract<'_> {
fn reply(&self, deps: DepsMut, env: Env, reply: Reply) -> Result<Response, ContractError> {
todo!()
}
// [...]
}
#[entry_point]
fn reply(deps: DepsMut, env: Env, reply: Reply) -> Result<Response, ContractError> {
&MyContract::new().reply(deps, env, reply)
}
在合约类型中创建一个入口函数非常重要,这样它就可以访问该类型上定义的所有状态访问器。
覆盖入口点
有一种方法可以覆盖入口点或添加自定义定义的入口点。以下代码示例:
#[cw_serde]
pub enum UserExecMsg {
IncreaseByOne {},
}
pub fn increase_by_one(ctx: ExecCtx) -> StdResult<Response> {
crate::COUNTER.update(ctx.deps.storage, |count| -> Result<u32, StdError> {
Ok(count + 1)
})?;
Ok(Response::new())
}
#[cw_serde]
pub enum CustomExecMsg {
ContractExec(crate::ContractExecMsg),
CustomExec(UserExecMsg),
}
impl CustomExecMsg {
pub fn dispatch(self, ctx: (DepsMut, Env, MessageInfo)) -> StdResult<Response> {
match self {
CustomExecMsg::ContractExec(msg) => {
msg.dispatch(&crate::contract::Contract::new(), ctx)
}
CustomExecMsg::CustomExec(_) => increase_by_one(ctx.into()),
}
}
}
#[entry_point]
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: CustomExecMsg,
) -> StdResult<Response> {
msg.dispatch((deps, env, info))
}
可以定义一个自定义的 exec
消息,该消息将调度由您的合约生成的和由您定义的生成的消息。要使用此自定义入口点与 contract
宏一起使用,您可以在 sv::override_entry_point(...)
属性中添加。
#[contract]
#[sv::override_entry_point(exec=crate::entry_points::execute(crate::exec::CustomExecMsg))]
#[sv::override_entry_point(sudo=crate::entry_points::sudo(crate::SudoMsg))]
impl Contract {
// ...
}
可以像这样覆盖所有消息类型。除了入口点路径之外,您还必须提供自定义消息的类型。在 multitest helpers
中需要反序列化消息。
多测试
Sylvia 还为测试合约生成了一些辅助工具,这些工具隐藏在 mt
功能标志后面,需要启用。
当合约在 wasm
目标中构建时,必须确保没有设置 mt
标志,因为某些依赖项在 Wasm 上不可构建。建议在 dev-dependencies
中添加带有 mt
启用的额外 sylvia
入口,并在合约上添加 mt
功能,这样就可以在其他合约测试中启用 mt 工具。以下是一个 Cargo.toml
示例:
[package]
name = "my-contract"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
library = []
mt = ["sylvia/mt"]
[dependencies]
sylvia = "0.10.0"
# [...]
[dev-dependencies]
sylvia = { version = "0.10.0", features = ["mt"] }
以下是示例代码:
#[cfg(test)]
mod tests {
use super::*;
use sylvia::multitest::App;
#[test]
fn counter_test() {
let app = App::default();
let owner = "owner";
let code_id = contract::CodeId::store_code(&app);
let contract = code_id.instantiate(3)
.with_label("My contract")
.call(&owner)
.unwrap();
let counter = contract.counter().unwrap();
assert_eq!(counter, contract::CounterResp { counter: 3});
contract.increment().call(&owner).unwrap();
let counter = contract.counter().unwrap();
assert_eq!(counter, contract::CounterResp { counter: 4});
}
}
请注意我这里使用的 contract
模块 - 这是一个与前面的代码略有不同的微小变化 - 我假设所有合约代码都位于 contract
模块中,以确保使用类型的位置清晰。因此,如果我使用 contract::something
,它就是原始合约模块(很可能是 Sylvia 生成的)中的 something
。
首先——我们不直接使用 cw-multi-test
应用。相反,我们在其上使用 sylvia
包装器。它内部包含原始的多测试应用,但以内部可变的方式执行,这使得它可以避免在各个地方传递。它增加了一些开销,但对于测试代码来说不应有问题。
我们首先使用为每个单独的 Sylvia 合约生成的 CodeId
类型。它的目的是抽象化在区块链中存储合约。它确保创建合约对象并将其传递给多测试。
合约的 CodeId
类型有一个特别有趣的功能——instantiate
,它调用一个实例化函数。它接受与合约中实例化函数相同的参数,除了 Sylvia 的实用工具会提供的上下文。
该函数不会立即实例化合约——相反,它返回所谓的 InstantiationProxy
。我们决定不强迫用户在每次实例化调用时设置所有元数据——管理员、标签和要发送的资金,因为在绝大多数情况下,它们都是无关紧要的。相反,InstantiationProxy
提供了 with_label
、with_funds
和 with_amin
函数,这些函数以构建器模式风格设置这些元字段。
当实例化准备就绪时,我们调用 call
函数,传递消息发送者——我们本可以添加另一个 with_sender
函数,但我们决定,由于发送者每次都必须传递,我们可以在这一点上节省一些击键。
当涉及到执行消息时,事情类似。最大的不同之处在于我们不在 CodeId
上调用它,而是在已实例化的合约上调用。我们还需要设置的字段也较少——执行代理仅提供 with_funds
函数。
所有实例化和执行函数都返回类型为 cw_multi_test::AppResponse, ContractError<cw_multi_test::AppResponse, ContractError>
的类型,其中 ContractError
是合约的错误类型。
多测试中的接口项目
声明所有接口方法的特质直接在合约代理类型上实现。
use contract::mt::Group;
#[test]
fn member_test() {
let app = App::default();
let owner = "owner";
let member = "john";
let code_id = contract::mt::CodeId::store_code(&app);
let contract = code_id.instantiate(0)
.with_label("My contract")
.call(&owner);
contract
.add_member(member.to_owned())
.call(&owner);
let resp = contract
.is_member(member.to_owned())
assert_eq!(resp, group::IsMemberResp { is_member: true });
}
泛型
接口
在接口上定义关联类型与在常规特质上定义它们一样简单。
#[interface]
pub trait Generic {
type Error: From<StdError>;
type ExecParam: CustomMsg;
type QueryParam: CustomMsg;
type RetType: CustomMsg;
#[sv::msg(exec)]
fn generic_exec(
&self,
ctx: ExecCtx,
msgs: Vec<CosmosMsg<Self::ExecParam>>,
) -> Result<Response, Self::Error>;
#[sv::msg(query)]
fn generic_query(&self, ctx: QueryCtx, param: Self::QueryParam) -> Result<Self::RetType, Self::Error>;
}
泛型合约
合约中的泛型可能用作泛型字段类型,或用作消息中返回类型的泛型参数。当 Sylvia 生成消息的枚举时,仅用于相应方法的泛型将包含在给定的生成消息类型中。
使用示例
pub struct GenericContract<
InstantiateParam,
ExecParam,
FieldType,
> {
_field: Item<'static, FieldType>,
_phantom: std::marker::PhantomData<(
InstantiateParam,
ExecParam,
)>,
}
#[contract]
impl<InstantiateParam, ExecParam, FieldType>
GenericContract<InstantiateParam, ExecParam, FieldType>
where
for<'msg_de> InstantiateParam: CustomMsg + Deserialize<'msg_de> + 'msg_de,
ExecParam: CustomMsg + DeserializeOwned + 'static,
FieldType: 'static,
{
pub const fn new() -> Self {
Self {
_field: Item::new("field"),
_phantom: std::marker::PhantomData,
}
}
#[sv::msg(instantiate)]
pub fn instantiate(
&self,
_ctx: InstantiateCtx,
_msg: InstantiateParam,
) -> StdResult<Response> {
Ok(Response::new())
}
#[sv::msg(exec)]
pub fn contract_execute(
&self,
_ctx: ExecCtx,
_msg: ExecParam,
) -> StdResult<Response> {
Ok(Response::new())
}
}
入口点中的泛型
入口点必须使用具体类型生成。在泛型合约上使用 entry_points
宏时,必须指定要使用的类型。我们通过 entry_points(generics<..>)
来做这件事
#[cfg_attr(not(feature = "library"), entry_points(generics<SvCustomMsg, SvCustomMsg, SvCustomMsg>))]
#[contract]
impl<InstantiateParam, ExecParam, FieldType>
GenericContract<InstantiateParam, ExecParam, FieldType>
where
for<'msg_de> InstantiateParam: CustomMsg + Deserialize<'msg_de> + 'msg_de,
ExecParam: CustomMsg + DeserializeOwned + 'static,
FieldType: 'static,
{
...
}
合约可以在自定义消息和查询的位置定义一个泛型类型。在这种情况下,我们必须使用 custom
通知 entry_points
宏。
#[cfg_attr(not(feature = "library"), entry_points(generics<SvCustomMsg, SvCustomMsg, SvCustomMsg>, custom(msg=SvCustomMsg, query=SvCustomQuery))]
#[contract]
#[sv::custom(msg=MsgT, query=QueryT)]
impl<InstantiateParam, ExecParam, FieldType, MsgT, QueryT>
GenericContract<InstantiateParam, ExecParam, FieldType, MsgT, QueryT>
where
for<'msg_de> InstantiateParam: CustomMsg + Deserialize<'msg_de> + 'msg_de,
ExecParam: CustomMsg + DeserializeOwned + 'static,
FieldType: 'static,
{
...
}
生成模式
Sylvia 的设计目的是生成所有 cosmwasm-schema
所依赖的代码 - 这使得生成合同的架构变得非常容易。只需添加一个 bin/schema.rs
模块,该模块会被识别为二进制文件,并在其中添加一个简单的 main 函数。
use cosmwasm_schema::write_api;
use my_contract_crate::contract::{ContractExecMsg, ContractQueryMsg, InstantiateMsg};
fn main() {
write_api! {
instantiate: InstantiateMsg,
execute: ContractExecMsg,
query: ContractQueryMsg,
}
}
路线图
目前 Sylvia 处于采用阶段,但我们仍在为用户提供更多功能。以下是未来几个月的大致路线图。
- 回复 - Sylvia 仍然需要支持基本的 CosmWasm 消息,即回复。我们希望它们更智能,以便更直接地表达发送的消息和执行的处理程序之间的关联,而不是隐藏在回复调度器中。
- 迁移 - 另一个我们不支持的 重要消息,但原因与回复类似 - 我们希望它们更智能。我们希望为您提供一个很好的方式来提供合约的升级 API,这将负责其版本控制。
- IBC - 我们也想为您提供优秀的 IBC API!然而,请稍等,我们必须首先理解这里最好的模式。
- 更好的工具支持 - Sylvia 的最大问题是它生成的代码不是微不足道的,并且并非所有工具都能很好地处理它。我们正在努力改进这方面的用户体验。
故障排除
为了获得更多描述性的错误消息,请考虑使用夜间工具链(为 cargo 添加 +nightly
参数)。
- 合约接口中缺少消息 - 您可能缺少
#[sv::messages)]
属性。
依赖项
~7–11MB
~206K SLoC