Skip to content

Commit ff4c95c

Browse files
committed
chore: disable raw transfer if royalty is set
1 parent 1206020 commit ff4c95c

File tree

5 files changed

+231
-183
lines changed

5 files changed

+231
-183
lines changed

contracts/asset/README.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Asset Contract
2+
3+
A CosmWasm `cw721`-compatible asset contract for the XION network that layers marketplace
4+
functionality and a pluggable execution pipeline on top of NFT collections. The library exposes
5+
traits that make it easy to extend vanilla `cw721` contracts with listing, reservation, and plugin
6+
logic without rewriting the core token implementation.
7+
8+
## Core Types and Traits
9+
10+
- `AssetContract` / `DefaultAssetContract`
11+
- Thin wrapper around the canonical `cw721` storage helpers (`Cw721Config`) plus marketplace
12+
indices (`IndexedMap` for listings and plugin registry).
13+
- `DefaultAssetContract` picks `AssetExtensionExecuteMsg` as the extension message so you get the
14+
marketplace verbs (list, reserve, delist, buy) out of the box.
15+
16+
- `SellableAsset`
17+
- Trait that adds four high-level marketplace entry points: `list`, `reserve`, `delist`, and `buy`.
18+
- Each method wires through the shared `AssetConfig` helpers defined in `execute.rs`, handling
19+
ownership checks, price validation, and state transitions.
20+
- Implemented for `AssetContract`, so adopting the trait is as simple as embedding the contract
21+
struct in your project.
22+
23+
- `PluggableAsset`
24+
- Trait that wraps `cw721::Cw721Execute::execute` with a plugin pipeline (`execute_pluggable`).
25+
- Hooks (`on_list_plugin`, `on_buy_plugin`, etc.) run before the base action and can mutate a
26+
`PluginCtx` shared across plugins. The returned `Response` from plugins is merged back into the
27+
main execution result, allowing plugins to enqueue messages, attributes, or data.
28+
- `DefaultAssetContract` implements the trait using `DefaultXionAssetContext`, giving you sensible
29+
defaults while still allowing custom contexts if you implement the trait yourself.
30+
31+
## Messages and State
32+
33+
- `AssetExtensionExecuteMsg` provides the marketplace verbs clients call via the `cw721` execute
34+
route. These are automatically dispatched through `SellableAsset` when `DefaultAssetContract`
35+
handles `execute_extension`.
36+
- `Reserve` captures an optional reservation window (`Expiration`) and address, used to gate buys.
37+
- `ListingInfo` stores price, seller, reserve data, and a snapshot of the NFT metadata so listings
38+
remain consistent even if the token attributes change later.
39+
- `AssetConfig` centralizes the contract's storage maps and exposes helper constructors so you can
40+
use custom storage keys when embedding the contract inside another crate.
41+
42+
## Plugin System
43+
44+
`plugin.rs` includes a `Plugin` enum and a default plugin module:
45+
46+
- Price guards (`ExactPrice`, `MinimumPrice`).
47+
- Temporal restrictions (`NotBefore`, `NotAfter`, `TimeLock`).
48+
- Access control (`AllowedMarketplaces`, `RequiresProof`).
49+
- Currency allow-listing (`AllowedCurrencies`).
50+
- Royalty payouts (`Royalty`).
51+
52+
The provided `default_plugins` module contains ready-to-use helpers that enforce the relevant rules
53+
and can enqueue `BankMsg::Send` payouts (e.g., royalties) or raise errors to abort the action.
54+
Register plugins per collection with `AssetConfig::collection_plugins` and they will be invoked by
55+
`execute_pluggable` automatically.
56+
57+
## Using the Library
58+
59+
1. **Instantiate `cw721` normally**
60+
```rust
61+
pub type InstantiateMsg = asset::msg::InstantiateMsg<MyCollectionExtension>;
62+
```
63+
Use the standard `cw721` instantiate flow; the asset contract reuses `Cw721InstantiateMsg`.
64+
65+
2. **Embed the contract**
66+
```rust
67+
use asset::traits::{AssetContract, DefaultAssetContract};
68+
69+
pub struct AppContract {
70+
asset: DefaultAssetContract<'static, MyNftExtension, MyNftMsg, MyCollectionExtension, MyCollectionMsg>,
71+
}
72+
73+
impl Default for AppContract {
74+
fn default() -> Self {
75+
Self { asset: AssetContract::default() }
76+
}
77+
}
78+
```
79+
80+
3. **Expose execute entry points**
81+
```rust
82+
use asset::traits::{PluggableAsset, SellableAsset};
83+
84+
pub fn execute(
85+
deps: DepsMut,
86+
env: Env,
87+
info: MessageInfo,
88+
msg: asset::msg::ExecuteMsg<MyNftMsg, MyCollectionMsg, asset::msg::AssetExtensionExecuteMsg>,
89+
) -> Result<Response, ContractError> {
90+
Ok(APP_CONTRACT.asset.execute_pluggable(deps, &env, &info, msg)?)
91+
}
92+
```
93+
The `PluggableAsset` trait forwards marketplace operations to the relevant hooks and finally to
94+
the base `cw721` implementation.
95+
96+
4. **Dispatch marketplace operations**
97+
```rust
98+
use asset::msg::AssetExtensionExecuteMsg;
99+
use cosmwasm_std::{Coin, to_json_binary, CosmosMsg};
100+
101+
let list = CosmosMsg::Wasm(WasmMsg::Execute {
102+
contract_addr: collection_addr.into(),
103+
funds: vec![],
104+
msg: to_json_binary(&AssetExtensionExecuteMsg::List {
105+
token_id: "token-1".into(),
106+
price: Coin::new(1_000_000u128, "uxion"),
107+
reservation: None,
108+
})?,
109+
});
110+
```
111+
Similar patterns apply for `Reserve`, `Buy` (attach payment funds), and `Delist` messages.
112+
113+
## Feature Flags
114+
115+
- `asset_base` (default): ships the standard marketplace + plugin behavior.
116+
- `crossmint`: alternative configuration for cross-minting scenarios (mutually exclusive with
117+
`asset_base`). Ensure only one of these is enabled at a time.
118+
119+
## Testing
120+
121+
`src/test.rs` demonstrates how to wire mocks for the `SellableAsset` entry points and validate
122+
plugin flows. Use it as a reference when building integration tests around custom plugin behavior.

contracts/asset/src/contracts/Readme.md

Whitespace-only changes.

contracts/asset/src/plugin.rs

Lines changed: 77 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,34 @@ where
3131
pub env: Env,
3232
pub info: MessageInfo,
3333

34+
// royalty info
35+
pub royalty: RoyaltyInfo,
36+
3437
/// The response being built up by the plugins.
3538
pub response: Response<TCustomResponseMsg>,
3639

3740
pub data: Context,
3841
}
3942

43+
pub struct RoyaltyInfo {
44+
pub collection_royalty_bps: Option<u16>,
45+
pub collection_royalty_recipient: Option<Addr>,
46+
pub collection_royalty_on_primary: Option<bool>,
47+
48+
pub primary_complete: bool,
49+
}
50+
51+
impl Default for RoyaltyInfo {
52+
fn default() -> Self {
53+
RoyaltyInfo {
54+
collection_royalty_bps: None,
55+
collection_royalty_recipient: None,
56+
collection_royalty_on_primary: None,
57+
primary_complete: false,
58+
}
59+
}
60+
}
61+
4062
pub struct DefaultXionAssetContext {
4163
pub token_id: String,
4264
pub seller: Option<Addr>,
@@ -50,16 +72,6 @@ pub struct DefaultXionAssetContext {
5072
pub reservation: Option<Reserve>,
5173
pub time_lock: Option<Duration>,
5274

53-
pub collection_royalty_bps: Option<u16>,
54-
pub collection_royalty_recipient: Option<Addr>,
55-
pub collection_royalty_on_primary: Option<bool>,
56-
57-
pub nft_royalty_bps: Option<u16>,
58-
pub nft_royalty_recipient: Option<Addr>,
59-
pub nft_royalty_on_primary: Option<bool>,
60-
61-
pub primary_complete: bool,
62-
6375
pub allowed_marketplaces: Option<Vec<Addr>>,
6476
pub allowed_currencies: Option<Vec<Coin>>,
6577
}
@@ -75,13 +87,6 @@ impl Default for DefaultXionAssetContext {
7587
not_before: Expiration::Never {},
7688
not_after: Expiration::Never {},
7789
reservation: None,
78-
collection_royalty_bps: None,
79-
collection_royalty_recipient: None,
80-
collection_royalty_on_primary: None,
81-
nft_royalty_bps: None,
82-
nft_royalty_recipient: None,
83-
nft_royalty_on_primary: None,
84-
primary_complete: false,
8590
allowed_marketplaces: None,
8691
allowed_currencies: None,
8792
time_lock: None,
@@ -155,9 +160,9 @@ impl Plugin {
155160
recipient,
156161
on_primary,
157162
} => {
158-
ctx.data.collection_royalty_bps = Some(*bps);
159-
ctx.data.collection_royalty_recipient = Some((*recipient).clone());
160-
ctx.data.collection_royalty_on_primary = Some(*on_primary);
163+
ctx.royalty.collection_royalty_bps = Some(*bps);
164+
ctx.royalty.collection_royalty_recipient = Some((*recipient).clone());
165+
ctx.royalty.collection_royalty_on_primary = Some(*on_primary);
161166
default_plugins::royalty_plugin(ctx)?;
162167
}
163168
Plugin::AllowedMarketplaces { marketplaces } => {
@@ -175,6 +180,23 @@ impl Plugin {
175180
}
176181
Ok(true)
177182
}
183+
184+
pub fn run_raw_transfer_plugin<T, U: CustomMsg>(&self, ctx: &mut PluginCtx<T, U>) -> StdResult<bool> {
185+
match self {
186+
Plugin::Royalty {
187+
bps,
188+
recipient,
189+
on_primary,
190+
} => {
191+
ctx.royalty.collection_royalty_bps = Some(*bps);
192+
ctx.royalty.collection_royalty_recipient = Some((*recipient).clone());
193+
ctx.royalty.collection_royalty_on_primary = Some(*on_primary);
194+
default_plugins::is_transfer_enabled_plugin(ctx)?;
195+
}
196+
_ => {}
197+
}
198+
Ok(true)
199+
}
178200
}
179201

180202
/// The concept of a plugin is to be able to hook into the execution flow when a certain action
@@ -263,8 +285,15 @@ pub trait PluggableAsset<
263285
&self,
264286
_recipient: &String,
265287
_token_id: &String,
266-
_ctx: &mut PluginCtx<Context, TCustomResponseMsg>,
288+
ctx: &mut PluginCtx<Context, TCustomResponseMsg>,
267289
) -> StdResult<bool> {
290+
// for transfers we run the royalty plugin if set
291+
let royalty_plugin = AssetConfig::<TNftExtension>::default()
292+
.collection_plugins
293+
.may_load(ctx.deps.storage, "Royalty")?;
294+
if let Some(plugin) = royalty_plugin {
295+
plugin.run_raw_transfer_plugin(ctx)?;
296+
}
268297
Ok(true)
269298
}
270299

@@ -343,25 +372,6 @@ where
343372
TNftExtensionMsg: Cw721CustomMsg,
344373
TNftExtensionMsg: StateFactory<TNftExtension>,
345374
{
346-
fn on_transfer_plugin(
347-
&self,
348-
recipient: &String,
349-
token_id: &String,
350-
ctx: &mut DefaultPluginCtx,
351-
) -> StdResult<bool> {
352-
// for transfers we run the royalty plugin if set
353-
let royalty_plugin = AssetConfig::<TNftExtension>::default()
354-
.collection_plugins
355-
.may_load(ctx.deps.storage, "Royalty")?;
356-
ctx.data.token_id = token_id.to_string();
357-
ctx.data.buyer = Some(ctx.deps.api.addr_validate(&recipient)?);
358-
ctx.data.seller = Some(ctx.info.sender.clone());
359-
if let Some(plugin) = royalty_plugin {
360-
plugin.run_asset_plugin(ctx)?;
361-
}
362-
Ok(true)
363-
}
364-
365375
fn on_update_extension_plugin<'a>(
366376
&self,
367377
msg: &AssetExtensionExecuteMsg,
@@ -509,6 +519,7 @@ where
509519
env: env.clone(),
510520
info: info.clone(),
511521
response: Response::default(),
522+
royalty: RoyaltyInfo::default(),
512523
data: DefaultXionAssetContext::default(),
513524
}
514525
}
@@ -626,33 +637,35 @@ pub mod default_plugins {
626637

627638
pub fn royalty_plugin(ctx: &mut PluginCtx<DefaultXionAssetContext, Empty>) -> StdResult<bool> {
628639
let (recipient, bps, on_primary) = match (
629-
ctx.data.nft_royalty_recipient.clone(),
630-
ctx.data.nft_royalty_bps,
631-
ctx.data.nft_royalty_on_primary,
640+
ctx.royalty.collection_royalty_recipient.clone(),
641+
ctx.royalty.collection_royalty_bps,
642+
ctx.royalty.collection_royalty_on_primary,
632643
) {
633644
(Some(recipient), Some(bps), on_primary) => (recipient, bps, on_primary),
634-
_ => match (
635-
ctx.data.collection_royalty_recipient.clone(),
636-
ctx.data.collection_royalty_bps,
637-
ctx.data.collection_royalty_on_primary,
638-
) {
639-
(Some(recipient), Some(bps), on_primary) => (recipient, bps, on_primary),
640-
_ => return Ok(true),
641-
},
645+
_ => return Ok(true),
642646
};
643647

644648
if bps == 0 {
645649
return Ok(true);
646650
}
647651

648-
let is_primary_sale = !ctx.data.primary_complete;
652+
let is_primary_sale = !ctx.royalty.primary_complete;
649653
let collect_on_primary = on_primary.unwrap_or(false);
650654
let should_collect = !is_primary_sale || collect_on_primary;
651655

652656
if !should_collect {
653657
return Ok(true);
654658
}
655659

660+
if let Some(ask_price) = &ctx.data.ask_price {
661+
if ask_price.amount.is_zero() {
662+
return Ok(true);
663+
}
664+
} else {
665+
return Err(cosmwasm_std::StdError::generic_err(
666+
"No ask price set for royalty calculation".to_string(),
667+
))?;
668+
}
656669
let fund = ctx
657670
.info
658671
.funds
@@ -754,6 +767,18 @@ pub mod default_plugins {
754767
)));
755768
}
756769
}
770+
Ok(true)
771+
}
772+
/// This plugin checks that raw transfers are enabled. If royalty info is set,
773+
/// transfers are disabled and all transfers must go through the buy flow.
774+
pub fn is_transfer_enabled_plugin<T, U: CustomMsg>(ctx: &mut PluginCtx<T, U>) -> StdResult<bool> {
775+
if ctx.royalty.collection_royalty_bps.is_some()
776+
&& ctx.royalty.collection_royalty_recipient.is_some()
777+
{
778+
return Err(cosmwasm_std::StdError::generic_err(
779+
"raw transfers are disabled when royalty info is set",
780+
));
781+
}
757782

758783
Ok(true)
759784
}

0 commit comments

Comments
 (0)