Initial Commit
This commit is contained in:
commit
80d1498a67
13 changed files with 560 additions and 0 deletions
4
bisenzone-cw-mvp/.cargo/config
Normal file
4
bisenzone-cw-mvp/.cargo/config
Normal file
|
@ -0,0 +1,4 @@
|
|||
[alias]
|
||||
wasm = "build --release --lib --target wasm32-unknown-unknown"
|
||||
unit-test = "test --lib"
|
||||
schema = "run --bin schema"
|
75
bisenzone-cw-mvp/.github/workflows/Basic.yml
vendored
Normal file
75
bisenzone-cw-mvp/.github/workflows/Basic.yml
vendored
Normal file
|
@ -0,0 +1,75 @@
|
|||
# Based on https://github.com/actions-rs/example/blob/master/.github/workflows/quickstart.yml
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
name: Basic
|
||||
|
||||
jobs:
|
||||
|
||||
test:
|
||||
name: Test Suite
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install stable toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: 1.60.0
|
||||
target: wasm32-unknown-unknown
|
||||
override: true
|
||||
|
||||
- name: Run unit tests
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: unit-test
|
||||
args: --locked
|
||||
env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
- name: Compile WASM contract
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: wasm
|
||||
args: --locked
|
||||
env:
|
||||
RUSTFLAGS: "-C link-arg=-s"
|
||||
|
||||
lints:
|
||||
name: Lints
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install stable toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: 1.60.0
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Run cargo fmt
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: fmt
|
||||
args: --all -- --check
|
||||
|
||||
- name: Run cargo clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: clippy
|
||||
args: -- -D warnings
|
||||
|
||||
- name: Generate Schema
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: schema
|
||||
args: --locked
|
||||
|
||||
- name: Schema Changes
|
||||
# fails if any changes not committed
|
||||
run: git diff --exit-code schema
|
35
bisenzone-cw-mvp/.github/workflows/Release.yml
vendored
Normal file
35
bisenzone-cw-mvp/.github/workflows/Release.yml
vendored
Normal file
|
@ -0,0 +1,35 @@
|
|||
name: release wasm
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
- name: Install cargo-run-script
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: install
|
||||
args: cargo-run-script
|
||||
- name: Run cargo optimize
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: run-script
|
||||
args: optimize
|
||||
- name: Get release ID
|
||||
id: get_release
|
||||
uses: bruceadams/get-release@v1.2.3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
- name: Upload optimized wasm
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ./artifacts/*.wasm
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
file_glob: true
|
16
bisenzone-cw-mvp/.gitignore
vendored
Normal file
16
bisenzone-cw-mvp/.gitignore
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
# Build results
|
||||
/target
|
||||
/schema
|
||||
|
||||
# Cargo+Git helper file (https://github.com/rust-lang/cargo/blob/0.44.1/src/cargo/sources/git/utils.rs#L320-L327)
|
||||
.cargo-ok
|
||||
|
||||
# Text file backups
|
||||
**/*.rs.bk
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDEs
|
||||
*.iml
|
||||
.idea
|
85
bisenzone-cw-mvp/README.md
Normal file
85
bisenzone-cw-mvp/README.md
Normal file
|
@ -0,0 +1,85 @@
|
|||
# CosmWasm Starter Pack
|
||||
|
||||
This is a template to build smart contracts in Rust to run inside a
|
||||
[Cosmos SDK](https://github.com/cosmos/cosmos-sdk) module on all chains that enable it.
|
||||
To understand the framework better, please read the overview in the
|
||||
[cosmwasm repo](https://github.com/CosmWasm/cosmwasm/blob/master/README.md),
|
||||
and dig into the [cosmwasm docs](https://www.cosmwasm.com).
|
||||
This assumes you understand the theory and just want to get coding.
|
||||
|
||||
## Creating a new repo from template
|
||||
|
||||
Assuming you have a recent version of Rust and Cargo installed
|
||||
(via [rustup](https://rustup.rs/)),
|
||||
then the following should get you a new repo to start a contract:
|
||||
|
||||
Install [cargo-generate](https://github.com/ashleygwilliams/cargo-generate) and cargo-run-script.
|
||||
Unless you did that before, run this line now:
|
||||
|
||||
```sh
|
||||
cargo install cargo-generate --features vendored-openssl
|
||||
cargo install cargo-run-script
|
||||
```
|
||||
|
||||
Now, use it to create your new contract.
|
||||
Go to the folder in which you want to place it and run:
|
||||
|
||||
**Latest**
|
||||
|
||||
```sh
|
||||
cargo generate --git https://github.com/CosmWasm/cw-template.git --name PROJECT_NAME
|
||||
```
|
||||
|
||||
For cloning minimal code repo:
|
||||
|
||||
```sh
|
||||
cargo generate --git https://github.com/CosmWasm/cw-template.git --name PROJECT_NAME -d minimal=true
|
||||
```
|
||||
|
||||
You will now have a new folder called `PROJECT_NAME` (I hope you changed that to something else)
|
||||
containing a simple working contract and build system that you can customize.
|
||||
|
||||
## Create a Repo
|
||||
|
||||
After generating, you have a initialized local git repo, but no commits, and no remote.
|
||||
Go to a server (eg. github) and create a new upstream repo (called `YOUR-GIT-URL` below).
|
||||
Then run the following:
|
||||
|
||||
```sh
|
||||
# this is needed to create a valid Cargo.lock file (see below)
|
||||
cargo check
|
||||
git branch -M main
|
||||
git add .
|
||||
git commit -m 'Initial Commit'
|
||||
git remote add origin YOUR-GIT-URL
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
## CI Support
|
||||
|
||||
We have template configurations for both [GitHub Actions](.github/workflows/Basic.yml)
|
||||
and [Circle CI](.circleci/config.yml) in the generated project, so you can
|
||||
get up and running with CI right away.
|
||||
|
||||
One note is that the CI runs all `cargo` commands
|
||||
with `--locked` to ensure it uses the exact same versions as you have locally. This also means
|
||||
you must have an up-to-date `Cargo.lock` file, which is not auto-generated.
|
||||
The first time you set up the project (or after adding any dep), you should ensure the
|
||||
`Cargo.lock` file is updated, so the CI will test properly. This can be done simply by
|
||||
running `cargo check` or `cargo unit-test`.
|
||||
|
||||
## Using your project
|
||||
|
||||
Once you have your custom repo, you should check out [Developing](./Developing.md) to explain
|
||||
more on how to run tests and develop code. Or go through the
|
||||
[online tutorial](https://docs.cosmwasm.com/) to get a better feel
|
||||
of how to develop.
|
||||
|
||||
[Publishing](./Publishing.md) contains useful information on how to publish your contract
|
||||
to the world, once you are ready to deploy it on a running blockchain. And
|
||||
[Importing](./Importing.md) contains information about pulling in other contracts or crates
|
||||
that have been published.
|
||||
|
||||
Please replace this README file with information about your specific project. You can keep
|
||||
the `Developing.md` and `Publishing.md` files as useful references, but please set some
|
||||
proper description in the README.
|
11
bisenzone-cw-mvp/src/bin/schema.rs
Normal file
11
bisenzone-cw-mvp/src/bin/schema.rs
Normal file
|
@ -0,0 +1,11 @@
|
|||
use cosmwasm_schema::write_api;
|
||||
|
||||
use cw_mtcs::msg::{ExecuteMsg, InstantiateMsg, QueryMsg};
|
||||
|
||||
fn main() {
|
||||
write_api! {
|
||||
instantiate: InstantiateMsg,
|
||||
execute: ExecuteMsg,
|
||||
query: QueryMsg,
|
||||
}
|
||||
}
|
156
bisenzone-cw-mvp/src/contract.rs
Normal file
156
bisenzone-cw-mvp/src/contract.rs
Normal file
|
@ -0,0 +1,156 @@
|
|||
#[cfg(not(feature = "library"))]
|
||||
use cosmwasm_std::entry_point;
|
||||
use cosmwasm_std::{to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult};
|
||||
use cw2::set_contract_version;
|
||||
|
||||
use crate::error::ContractError;
|
||||
use crate::msg::{ExecuteMsg, GetCountResponse, InstantiateMsg, QueryMsg};
|
||||
use crate::state::{State, STATE};
|
||||
|
||||
// version info for migration info
|
||||
const CONTRACT_NAME: &str = "crates.io:cw-mtcs";
|
||||
const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[cfg_attr(not(feature = "library"), entry_point)]
|
||||
pub fn instantiate(
|
||||
deps: DepsMut,
|
||||
_env: Env,
|
||||
info: MessageInfo,
|
||||
msg: InstantiateMsg,
|
||||
) -> Result<Response, ContractError> {
|
||||
let state = State {
|
||||
count: msg.count,
|
||||
owner: info.sender.clone(),
|
||||
};
|
||||
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
|
||||
STATE.save(deps.storage, &state)?;
|
||||
|
||||
Ok(Response::new()
|
||||
.add_attribute("method", "instantiate")
|
||||
.add_attribute("owner", info.sender)
|
||||
.add_attribute("count", msg.count.to_string()))
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "library"), entry_point)]
|
||||
pub fn execute(
|
||||
deps: DepsMut,
|
||||
_env: Env,
|
||||
info: MessageInfo,
|
||||
msg: ExecuteMsg,
|
||||
) -> Result<Response, ContractError> {
|
||||
match msg {
|
||||
ExecuteMsg::Increment {} => execute::increment(deps),
|
||||
ExecuteMsg::Reset { count } => execute::reset(deps, info, count),
|
||||
}
|
||||
}
|
||||
|
||||
pub mod execute {
|
||||
use super::*;
|
||||
|
||||
pub fn increment(deps: DepsMut) -> Result<Response, ContractError> {
|
||||
STATE.update(deps.storage, |mut state| -> Result<_, ContractError> {
|
||||
state.count += 1;
|
||||
Ok(state)
|
||||
})?;
|
||||
|
||||
Ok(Response::new().add_attribute("action", "increment"))
|
||||
}
|
||||
|
||||
pub fn reset(deps: DepsMut, info: MessageInfo, count: i32) -> Result<Response, ContractError> {
|
||||
STATE.update(deps.storage, |mut state| -> Result<_, ContractError> {
|
||||
if info.sender != state.owner {
|
||||
return Err(ContractError::Unauthorized {});
|
||||
}
|
||||
state.count = count;
|
||||
Ok(state)
|
||||
})?;
|
||||
Ok(Response::new().add_attribute("action", "reset"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "library"), entry_point)]
|
||||
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
|
||||
match msg {
|
||||
QueryMsg::GetCount {} => to_json_binary(&query::count(deps)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub mod query {
|
||||
use super::*;
|
||||
|
||||
pub fn count(deps: Deps) -> StdResult<GetCountResponse> {
|
||||
let state = STATE.load(deps.storage)?;
|
||||
Ok(GetCountResponse { count: state.count })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
|
||||
use cosmwasm_std::{coins, from_json};
|
||||
|
||||
#[test]
|
||||
fn proper_initialization() {
|
||||
let mut deps = mock_dependencies();
|
||||
|
||||
let msg = InstantiateMsg { count: 17 };
|
||||
let info = mock_info("creator", &coins(1000, "earth"));
|
||||
|
||||
// we can just call .unwrap() to assert this was a success
|
||||
let res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap();
|
||||
assert_eq!(0, res.messages.len());
|
||||
|
||||
// it worked, let's query the state
|
||||
let res = query(deps.as_ref(), mock_env(), QueryMsg::GetCount {}).unwrap();
|
||||
let value: GetCountResponse = from_json(&res).unwrap();
|
||||
assert_eq!(17, value.count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increment() {
|
||||
let mut deps = mock_dependencies();
|
||||
|
||||
let msg = InstantiateMsg { count: 17 };
|
||||
let info = mock_info("creator", &coins(2, "token"));
|
||||
let _res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap();
|
||||
|
||||
// beneficiary can release it
|
||||
let info = mock_info("anyone", &coins(2, "token"));
|
||||
let msg = ExecuteMsg::Increment {};
|
||||
let _res = execute(deps.as_mut(), mock_env(), info, msg).unwrap();
|
||||
|
||||
// should increase counter by 1
|
||||
let res = query(deps.as_ref(), mock_env(), QueryMsg::GetCount {}).unwrap();
|
||||
let value: GetCountResponse = from_json(&res).unwrap();
|
||||
assert_eq!(18, value.count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset() {
|
||||
let mut deps = mock_dependencies();
|
||||
|
||||
let msg = InstantiateMsg { count: 17 };
|
||||
let info = mock_info("creator", &coins(2, "token"));
|
||||
let _res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap();
|
||||
|
||||
// beneficiary can release it
|
||||
let unauth_info = mock_info("anyone", &coins(2, "token"));
|
||||
let msg = ExecuteMsg::Reset { count: 5 };
|
||||
let res = execute(deps.as_mut(), mock_env(), unauth_info, msg);
|
||||
match res {
|
||||
Err(ContractError::Unauthorized {}) => {}
|
||||
_ => panic!("Must return unauthorized error"),
|
||||
}
|
||||
|
||||
// only the original creator can reset the counter
|
||||
let auth_info = mock_info("creator", &coins(2, "token"));
|
||||
let msg = ExecuteMsg::Reset { count: 5 };
|
||||
let _res = execute(deps.as_mut(), mock_env(), auth_info, msg).unwrap();
|
||||
|
||||
// should now be 5
|
||||
let res = query(deps.as_ref(), mock_env(), QueryMsg::GetCount {}).unwrap();
|
||||
let value: GetCountResponse = from_json(&res).unwrap();
|
||||
assert_eq!(5, value.count);
|
||||
}
|
||||
}
|
13
bisenzone-cw-mvp/src/error.rs
Normal file
13
bisenzone-cw-mvp/src/error.rs
Normal file
|
@ -0,0 +1,13 @@
|
|||
use cosmwasm_std::StdError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ContractError {
|
||||
#[error("{0}")]
|
||||
Std(#[from] StdError),
|
||||
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized {},
|
||||
// Add any other custom errors you like here.
|
||||
// Look at https://docs.rs/thiserror/1.0.21/thiserror/ for details.
|
||||
}
|
47
bisenzone-cw-mvp/src/helpers.rs
Normal file
47
bisenzone-cw-mvp/src/helpers.rs
Normal file
|
@ -0,0 +1,47 @@
|
|||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use cosmwasm_std::{
|
||||
to_json_binary, Addr, CosmosMsg, CustomQuery, Querier, QuerierWrapper, StdResult, WasmMsg,
|
||||
WasmQuery,
|
||||
};
|
||||
|
||||
use crate::msg::{ExecuteMsg, GetCountResponse, QueryMsg};
|
||||
|
||||
/// CwTemplateContract is a wrapper around Addr that provides a lot of helpers
|
||||
/// for working with this.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
|
||||
pub struct CwTemplateContract(pub Addr);
|
||||
|
||||
impl CwTemplateContract {
|
||||
pub fn addr(&self) -> Addr {
|
||||
self.0.clone()
|
||||
}
|
||||
|
||||
pub fn call<T: Into<ExecuteMsg>>(&self, msg: T) -> StdResult<CosmosMsg> {
|
||||
let msg = to_json_binary(&msg.into())?;
|
||||
Ok(WasmMsg::Execute {
|
||||
contract_addr: self.addr().into(),
|
||||
msg,
|
||||
funds: vec![],
|
||||
}
|
||||
.into())
|
||||
}
|
||||
|
||||
/// Get Count
|
||||
pub fn count<Q, T, CQ>(&self, querier: &Q) -> StdResult<GetCountResponse>
|
||||
where
|
||||
Q: Querier,
|
||||
T: Into<String>,
|
||||
CQ: CustomQuery,
|
||||
{
|
||||
let msg = QueryMsg::GetCount {};
|
||||
let query = WasmQuery::Smart {
|
||||
contract_addr: self.addr().into(),
|
||||
msg: to_json_binary(&msg)?,
|
||||
}
|
||||
.into();
|
||||
let res: GetCountResponse = QuerierWrapper::<CQ>::new(querier).query(&query)?;
|
||||
Ok(res)
|
||||
}
|
||||
}
|
71
bisenzone-cw-mvp/src/integration_tests.rs
Normal file
71
bisenzone-cw-mvp/src/integration_tests.rs
Normal file
|
@ -0,0 +1,71 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::helpers::CwTemplateContract;
|
||||
use crate::msg::InstantiateMsg;
|
||||
use cosmwasm_std::{Addr, Coin, Empty, Uint128};
|
||||
use cw_multi_test::{App, AppBuilder, Contract, ContractWrapper, Executor};
|
||||
|
||||
pub fn contract_template() -> Box<dyn Contract<Empty>> {
|
||||
let contract = ContractWrapper::new(
|
||||
crate::contract::execute,
|
||||
crate::contract::instantiate,
|
||||
crate::contract::query,
|
||||
);
|
||||
Box::new(contract)
|
||||
}
|
||||
|
||||
const USER: &str = "USER";
|
||||
const ADMIN: &str = "ADMIN";
|
||||
const NATIVE_DENOM: &str = "denom";
|
||||
|
||||
fn mock_app() -> App {
|
||||
AppBuilder::new().build(|router, _, storage| {
|
||||
router
|
||||
.bank
|
||||
.init_balance(
|
||||
storage,
|
||||
&Addr::unchecked(USER),
|
||||
vec![Coin {
|
||||
denom: NATIVE_DENOM.to_string(),
|
||||
amount: Uint128::new(1),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
fn proper_instantiate() -> (App, CwTemplateContract) {
|
||||
let mut app = mock_app();
|
||||
let cw_template_id = app.store_code(contract_template());
|
||||
|
||||
let msg = InstantiateMsg { count: 1i32 };
|
||||
let cw_template_contract_addr = app
|
||||
.instantiate_contract(
|
||||
cw_template_id,
|
||||
Addr::unchecked(ADMIN),
|
||||
&msg,
|
||||
&[],
|
||||
"test",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cw_template_contract = CwTemplateContract(cw_template_contract_addr);
|
||||
|
||||
(app, cw_template_contract)
|
||||
}
|
||||
|
||||
mod count {
|
||||
use super::*;
|
||||
use crate::msg::ExecuteMsg;
|
||||
|
||||
#[test]
|
||||
fn count() {
|
||||
let (mut app, cw_template_contract) = proper_instantiate();
|
||||
|
||||
let msg = ExecuteMsg::Increment {};
|
||||
let cosmos_msg = cw_template_contract.call(msg).unwrap();
|
||||
app.execute(Addr::unchecked(USER), cosmos_msg).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
8
bisenzone-cw-mvp/src/lib.rs
Normal file
8
bisenzone-cw-mvp/src/lib.rs
Normal file
|
@ -0,0 +1,8 @@
|
|||
pub mod contract;
|
||||
mod error;
|
||||
pub mod helpers;
|
||||
pub mod integration_tests;
|
||||
pub mod msg;
|
||||
pub mod state;
|
||||
|
||||
pub use crate::error::ContractError;
|
26
bisenzone-cw-mvp/src/msg.rs
Normal file
26
bisenzone-cw-mvp/src/msg.rs
Normal file
|
@ -0,0 +1,26 @@
|
|||
use cosmwasm_schema::{cw_serde, QueryResponses};
|
||||
|
||||
#[cw_serde]
|
||||
pub struct InstantiateMsg {
|
||||
pub count: i32,
|
||||
}
|
||||
|
||||
#[cw_serde]
|
||||
pub enum ExecuteMsg {
|
||||
Increment {},
|
||||
Reset { count: i32 },
|
||||
}
|
||||
|
||||
#[cw_serde]
|
||||
#[derive(QueryResponses)]
|
||||
pub enum QueryMsg {
|
||||
// GetCount returns the current count as a json-encoded number
|
||||
#[returns(GetCountResponse)]
|
||||
GetCount {},
|
||||
}
|
||||
|
||||
// We define a custom struct for each query response
|
||||
#[cw_serde]
|
||||
pub struct GetCountResponse {
|
||||
pub count: i32,
|
||||
}
|
13
bisenzone-cw-mvp/src/state.rs
Normal file
13
bisenzone-cw-mvp/src/state.rs
Normal file
|
@ -0,0 +1,13 @@
|
|||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use cosmwasm_std::Addr;
|
||||
use cw_storage_plus::Item;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
|
||||
pub struct State {
|
||||
pub count: i32,
|
||||
pub owner: Addr,
|
||||
}
|
||||
|
||||
pub const STATE: Item<State> = Item::new("state");
|
Loading…
Reference in a new issue