Skip to main content

o2_deploy/
eval_shop.rs

1//! Optional EvalShop deployment and explicit, compare-first configuration.
2
3use anyhow::{
4    Context,
5    Result,
6    ensure,
7};
8use fuels::{
9    prelude::*,
10    types::Identity,
11};
12use o2_tools::eval_shop::{
13    DEFAULT_ADMIN_ROLE,
14    DISCOUNT_MANAGER_ROLE,
15    EvalShopDeployConfig,
16    EvalShopDeployment,
17    FEE_WITHDRAWER_ROLE,
18    PAUSER_ROLE,
19    PRICE_MANAGER_ROLE,
20};
21use serde::{
22    Deserialize,
23    Serialize,
24};
25use std::collections::HashSet;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct EvalShopPriceConfig {
30    pub tier_id: u64,
31    pub asset_id: AssetId,
32    /// Base units as a decimal string; explicit null removes the listing.
33    /// Required even when null, to prevent a missing field from disabling sales.
34    #[serde(deserialize_with = "read_price", serialize_with = "write_price")]
35    pub price: Option<u64>,
36}
37
38fn read_price<'de, D: serde::Deserializer<'de>>(
39    deserializer: D,
40) -> Result<Option<u64>, D::Error> {
41    Option::<String>::deserialize(deserializer)?
42        .map(|value| value.parse().map_err(serde::de::Error::custom))
43        .transpose()
44}
45
46fn write_price<S: serde::Serializer>(
47    price: &Option<u64>,
48    serializer: S,
49) -> Result<S::Ok, S::Error> {
50    price.map(|value| value.to_string()).serialize(serializer)
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct EvalShopConfig {
56    pub id: Option<ContractId>,
57    /// Required address:0x... or contract:0x... identity. Only this payee receives withdrawals.
58    pub treasury: String,
59    #[serde(default)]
60    pub prices: Vec<EvalShopPriceConfig>,
61    #[serde(default)]
62    pub price_managers: Vec<String>,
63    #[serde(default)]
64    pub remove_price_managers: Vec<String>,
65    #[serde(default)]
66    pub fee_withdrawers: Vec<String>,
67    #[serde(default)]
68    pub remove_fee_withdrawers: Vec<String>,
69    #[serde(default)]
70    pub pausers: Vec<String>,
71    #[serde(default)]
72    pub remove_pausers: Vec<String>,
73    #[serde(default)]
74    pub discount_managers: Vec<String>,
75    #[serde(default)]
76    pub remove_discount_managers: Vec<String>,
77}
78
79fn identity(value: &str) -> Result<Identity> {
80    let id = super::parse_margin_identity(value)?;
81    let nonzero = match id {
82        Identity::Address(address) => address != Address::zeroed(),
83        Identity::ContractId(contract) => contract != ContractId::zeroed(),
84    };
85    ensure!(nonzero, "EvalShop identity cannot be zero");
86    Ok(id)
87}
88
89pub(crate) fn validate_config(config: &EvalShopConfig) -> Result<()> {
90    identity(&config.treasury).context("invalid EvalShop treasury")?;
91    ensure!(
92        config.id != Some(ContractId::zeroed()),
93        "EvalShop id cannot be zero"
94    );
95    let mut prices = HashSet::new();
96    for price in &config.prices {
97        ensure!(
98            prices.insert((price.tier_id, price.asset_id)),
99            "duplicate EvalShop tier/asset price: {} / {}",
100            price.tier_id,
101            price.asset_id
102        );
103    }
104    for (grants, revokes) in [
105        (&config.price_managers, &config.remove_price_managers),
106        (&config.fee_withdrawers, &config.remove_fee_withdrawers),
107        (&config.pausers, &config.remove_pausers),
108        (&config.discount_managers, &config.remove_discount_managers),
109    ] {
110        let grants = grants
111            .iter()
112            .map(|s| identity(s))
113            .collect::<Result<Vec<_>>>()?;
114        for revoke in revokes {
115            ensure!(
116                !grants.contains(&identity(revoke)?),
117                "conflicting EvalShop role grant/revoke for {revoke}"
118            );
119        }
120    }
121    Ok(())
122}
123
124pub(crate) async fn deploy_and_reconcile<W: Account + Clone>(
125    wallet: &W,
126    config: &EvalShopConfig,
127    registry: ContractId,
128    salt: Salt,
129    params: &super::DeployParams,
130) -> Result<EvalShopConfig> {
131    validate_config(config)?;
132    let deployment = EvalShopDeployment::deploy(
133        wallet,
134        &EvalShopDeployConfig {
135            contract_id: config.id,
136            salt,
137            upgrade_bytecode: params.upgrade_bytecode,
138            ..Default::default()
139        },
140    )
141    .await?;
142    let shop = &deployment.shop;
143    let treasury = identity(&config.treasury)?;
144    if shop
145        .methods()
146        .registry()
147        .simulate(Execution::state_read_only())
148        .await?
149        .value
150        != Some(registry)
151    {
152        shop.methods()
153            .set_registry(registry)
154            .call()
155            .await
156            .context("set EvalShop registry")?;
157    }
158    if shop
159        .methods()
160        .treasury()
161        .simulate(Execution::state_read_only())
162        .await?
163        .value
164        != Some(treasury)
165    {
166        shop.methods()
167            .set_treasury(treasury)
168            .call()
169            .await
170            .context("set EvalShop treasury")?;
171    }
172    for (role, grants) in [
173        (PRICE_MANAGER_ROLE, &config.price_managers),
174        (FEE_WITHDRAWER_ROLE, &config.fee_withdrawers),
175        (PAUSER_ROLE, &config.pausers),
176        (DISCOUNT_MANAGER_ROLE, &config.discount_managers),
177    ] {
178        for value in grants {
179            let who = identity(value)?;
180            if !shop
181                .methods()
182                .has_role(role, who)
183                .simulate(Execution::state_read_only())
184                .await?
185                .value
186            {
187                shop.methods().grant_role(role, who).call().await?;
188            }
189        }
190    }
191    for entry in &config.prices {
192        let current = shop
193            .methods()
194            .price(entry.tier_id, entry.asset_id)
195            .simulate(Execution::state_read_only())
196            .await?
197            .value;
198        if current != entry.price {
199            ensure!(
200                shop.methods()
201                    .has_role(PRICE_MANAGER_ROLE, Identity::Address(wallet.address()))
202                    .simulate(Execution::state_read_only())
203                    .await?
204                    .value,
205                "EvalShop price reconciliation requires PRICE_MANAGER_ROLE; include the deployment wallet in eval_shop.price_managers or grant it explicitly"
206            );
207            shop.methods()
208                .set_price(entry.tier_id, entry.asset_id, entry.price)
209                .call()
210                .await
211                .context("set EvalShop price")?;
212        }
213    }
214    // The caller may deliberately retire its pricing authority in the same run
215    // that updates prices. Apply explicit revocations only after those writes.
216    for (role, revokes) in [
217        (PRICE_MANAGER_ROLE, &config.remove_price_managers),
218        (FEE_WITHDRAWER_ROLE, &config.remove_fee_withdrawers),
219        (PAUSER_ROLE, &config.remove_pausers),
220        (DISCOUNT_MANAGER_ROLE, &config.remove_discount_managers),
221    ] {
222        for value in revokes {
223            let who = identity(value)?;
224            if shop
225                .methods()
226                .has_role(role, who)
227                .simulate(Execution::state_read_only())
228                .await?
229                .value
230            {
231                shop.methods().revoke_role(role, who).call().await?;
232            }
233        }
234    }
235    if let Some(owner) = params.new_proxy_owner {
236        let desired =
237            o2_tools::generic_proxy::State::Initialized(Identity::Address(owner));
238        if deployment
239            .proxy
240            .methods()
241            .proxy_owner()
242            .simulate(Execution::state_read_only())
243            .await?
244            .value
245            != desired
246        {
247            deployment
248                .proxy
249                .methods()
250                .set_owner(Identity::Address(owner))
251                .call()
252                .await?;
253        }
254    }
255    if let Some(owner) = params.new_contract_owner {
256        let new_admin = Identity::Address(owner);
257        let caller = Identity::Address(wallet.address());
258        if !shop
259            .methods()
260            .has_role(DEFAULT_ADMIN_ROLE, new_admin)
261            .simulate(Execution::state_read_only())
262            .await?
263            .value
264        {
265            shop.methods()
266                .grant_role(DEFAULT_ADMIN_ROLE, new_admin)
267                .call()
268                .await?;
269        }
270        if caller != new_admin
271            && shop
272                .methods()
273                .has_role(DEFAULT_ADMIN_ROLE, caller)
274                .simulate(Execution::state_read_only())
275                .await?
276                .value
277        {
278            shop.methods()
279                .revoke_role(DEFAULT_ADMIN_ROLE, caller)
280                .call()
281                .await?;
282        }
283    }
284    Ok(EvalShopConfig {
285        id: Some(deployment.contract_id),
286        ..config.clone()
287    })
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn optional_prices_preserve_zero_and_require_explicit_null_for_removal() {
296        let asset = AssetId::new([1; 32]);
297        let value = serde_json::json!({"tier_id": 1, "asset_id": asset, "price": "0"});
298        let mut config: EvalShopPriceConfig = serde_json::from_value(value).unwrap();
299        assert_eq!(config.price, Some(0));
300        config.price = None;
301        let value = serde_json::to_value(&config).unwrap();
302        assert!(value["price"].is_null());
303        assert_eq!(
304            serde_json::from_value::<EvalShopPriceConfig>(value)
305                .unwrap()
306                .price,
307            None
308        );
309        assert!(
310            serde_json::from_value::<EvalShopPriceConfig>(
311                serde_json::json!({"tier_id": 1, "asset_id": asset})
312            )
313            .is_err()
314        );
315    }
316
317    #[test]
318    fn duplicate_prices_and_conflicting_roles_are_rejected() {
319        let owner = format!("address:{}", Address::new([1; 32]));
320        let mut config = EvalShopConfig {
321            id: None,
322            treasury: owner.clone(),
323            prices: vec![],
324            price_managers: vec![owner.clone()],
325            remove_price_managers: vec![owner],
326            fee_withdrawers: vec![],
327            remove_fee_withdrawers: vec![],
328            pausers: vec![],
329            remove_pausers: vec![],
330            discount_managers: vec![],
331            remove_discount_managers: vec![],
332        };
333        assert!(
334            validate_config(&config)
335                .unwrap_err()
336                .to_string()
337                .contains("conflicting")
338        );
339        config.remove_price_managers.clear();
340        let price = EvalShopPriceConfig {
341            tier_id: 1,
342            asset_id: AssetId::new([2; 32]),
343            price: Some(0),
344        };
345        config.prices = vec![price.clone(), price];
346        assert!(
347            validate_config(&config)
348                .unwrap_err()
349                .to_string()
350                .contains("duplicate")
351        );
352    }
353
354    #[test]
355    fn discount_manager_configuration_is_optional_and_validated() {
356        let owner = format!("address:{}", Address::new([1; 32]));
357        let mut config: EvalShopConfig = serde_json::from_value(serde_json::json!({
358            "treasury": owner,
359        }))
360        .unwrap();
361        assert!(config.discount_managers.is_empty());
362        assert!(config.remove_discount_managers.is_empty());
363        validate_config(&config).unwrap();
364
365        config.discount_managers.push(owner.clone());
366        config.remove_discount_managers.push(owner);
367        assert!(
368            validate_config(&config)
369                .unwrap_err()
370                .to_string()
371                .contains("conflicting")
372        );
373        config.remove_discount_managers.clear();
374        config.discount_managers = vec![format!("address:{}", Address::zeroed())];
375        assert!(validate_config(&config).is_err());
376        config.discount_managers.clear();
377        config.remove_discount_managers = vec!["invalid identity".into()];
378        assert!(validate_config(&config).is_err());
379    }
380}